Compare commits
263 Commits
4c6a0e0399
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b46104564 | ||
|
|
be0dd750eb | ||
|
|
a47f0fb72e | ||
| a309123b35 | |||
| 83b77615cf | |||
|
|
bca6670390 | ||
|
|
fbe0c92f0f | ||
|
|
08adf0f20d | ||
|
|
18d83091a9 | ||
|
|
01388a5c4f | ||
|
|
518282ecb8 | ||
|
|
39671ed70f | ||
|
|
3ad0e08d58 | ||
|
|
6f2b7eb45e | ||
|
|
3db2d39a58 | ||
|
|
c1c9f22111 | ||
| 8cbf6be50a | |||
|
|
bcb910140e | ||
|
|
29942feee9 | ||
|
|
84abfa2506 | ||
|
|
b36922756d | ||
|
|
da09df1e9d | ||
|
|
ee60f0756e | ||
|
|
6039d0a778 | ||
|
|
dc205ad56e | ||
|
|
f43cfe7ac6 | ||
|
|
9d424c7bd2 | ||
|
|
21e57634e0 | ||
| 3f21f521ea | |||
| 3a312d396e | |||
|
|
705d921c14 | ||
|
|
8cffbb990a | ||
|
|
7bd0b5fc52 | ||
|
|
6c2f9295be | ||
|
|
6ad77bc0e2 | ||
|
|
b0e93eedae | ||
|
|
d282abd146 | ||
|
|
2dca3253e6 | ||
|
|
416d144387 | ||
|
|
7c8538f5c6 | ||
|
|
0bea454dca | ||
|
|
8687be10e8 | ||
|
|
be55c6f43e | ||
|
|
35f06951a0 | ||
|
|
e412f80295 | ||
|
|
4f946a0566 | ||
|
|
2ed3562a00 | ||
|
|
81a6e43d7c | ||
|
|
f4ce3d9edf | ||
|
|
d9975813cb | ||
|
|
7ea558847d | ||
|
|
50525f82a1 | ||
|
|
0594831c9f | ||
|
|
25b8e45af8 | ||
|
|
3aafc50702 | ||
|
|
a228280ca4 | ||
|
|
9b1a40cea3 | ||
|
|
ea22901553 | ||
|
|
d74046498d | ||
|
|
f80a1bae78 | ||
|
|
fbffa07f74 | ||
|
|
635d835a50 | ||
|
|
ce382794ba | ||
|
|
0265ecfac2 | ||
|
|
16c2351160 | ||
|
|
7cd290d341 | ||
|
|
fcf1be211f | ||
|
|
eaa7f7275c | ||
|
|
71a8bb9740 | ||
|
|
db8b50f6d7 | ||
|
|
82edb2593c | ||
|
|
2e11f694f8 | ||
|
|
b75a8991ac | ||
|
|
339c748a0f | ||
|
|
c6084fe702 | ||
|
|
e4ddd21305 | ||
|
|
b27099c6d9 | ||
|
|
5013464a2c | ||
|
|
bef7d645a8 | ||
|
|
d39a32c0d8 | ||
|
|
039138f7e4 | ||
|
|
6cdd2fdf9c | ||
|
|
435f5cc65c | ||
|
|
cf069f3537 | ||
|
|
e03b2b3032 | ||
|
|
971aebd560 | ||
| 12883c5410 | |||
| ed3a178aa0 | |||
|
|
d43d8c692f | ||
| 79ddd41a49 | |||
| 303c36025b | |||
|
|
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 |
12
.kilocode/rules/kilo-rule.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# kilo-rule.md
|
||||
永远记得你是一名专业的 reac native 工程师,并且当前项目是一个 prebuild 之后的 expo react native 项目,应用场景永远是 ios,不要考虑 android, 代码设计优美、可读性高
|
||||
|
||||
## 指导原则
|
||||
|
||||
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
||||
- 不要尝试使用 `npm run ios` 命令
|
||||
- 优先使用 Liquid Glass 风格组件
|
||||
- 注重代码的可读性,尽量增加注释
|
||||
- 不要随意新增 md 文档
|
||||
|
||||
|
||||
167
.kilocode/rules/memory-bank-instructions.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Memory Bank
|
||||
|
||||
I am an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.
|
||||
|
||||
When I start a task, I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization.
|
||||
|
||||
## Memory Bank Structure
|
||||
|
||||
The Memory Bank consists of core files and optional context files, all in Markdown format.
|
||||
|
||||
### Core Files (Required)
|
||||
1. `brief.md`
|
||||
This file is created and maintained manually by the developer. Don't edit this file directly but suggest to user to update it if it can be improved.
|
||||
- Foundation document that shapes all other files
|
||||
- Created at project start if it doesn't exist
|
||||
- Defines core requirements and goals
|
||||
- Source of truth for project scope
|
||||
|
||||
2. `product.md`
|
||||
- Why this project exists
|
||||
- Problems it solves
|
||||
- How it should work
|
||||
- User experience goals
|
||||
|
||||
3. `context.md`
|
||||
This file should be short and factual, not creative or speculative.
|
||||
- Current work focus
|
||||
- Recent changes
|
||||
- Next steps
|
||||
|
||||
4. `architecture.md`
|
||||
- System architecture
|
||||
- Source Code paths
|
||||
- Key technical decisions
|
||||
- Design patterns in use
|
||||
- Component relationships
|
||||
- Critical implementation paths
|
||||
|
||||
5. `tech.md`
|
||||
- Technologies used
|
||||
- Development setup
|
||||
- Technical constraints
|
||||
- Dependencies
|
||||
- Tool usage patterns
|
||||
|
||||
### Additional Files
|
||||
Create additional files/folders within memory-bank/ when they help organize:
|
||||
- `tasks.md` - Documentation of repetitive tasks and their workflows
|
||||
- Complex feature documentation
|
||||
- Integration specifications
|
||||
- API documentation
|
||||
- Testing strategies
|
||||
- Deployment procedures
|
||||
|
||||
## Core workflows
|
||||
|
||||
### Memory Bank Initialization
|
||||
|
||||
The initialization step is CRITICALLY IMPORTANT and must be done with extreme thoroughness as it defines all future effectiveness of the Memory Bank. This is the foundation upon which all future interactions will be built.
|
||||
|
||||
When user requests initialization of the memory bank (command `initialize memory bank`), I'll perform an exhaustive analysis of the project, including:
|
||||
- All source code files and their relationships
|
||||
- Configuration files and build system setup
|
||||
- Project structure and organization patterns
|
||||
- Documentation and comments
|
||||
- Dependencies and external integrations
|
||||
- Testing frameworks and patterns
|
||||
|
||||
I must be extremely thorough during initialization, spending extra time and effort to build a comprehensive understanding of the project. A high-quality initialization will dramatically improve all future interactions, while a rushed or incomplete initialization will permanently limit my effectiveness.
|
||||
|
||||
After initialization, I will ask the user to read through the memory bank files and verify product description, used technologies and other information. I should provide a summary of what I've understood about the project to help the user verify the accuracy of the memory bank files. I should encourage the user to correct any misunderstandings or add missing information, as this will significantly improve future interactions.
|
||||
|
||||
### Memory Bank Update
|
||||
|
||||
Memory Bank updates occur when:
|
||||
1. Discovering new project patterns
|
||||
2. After implementing significant changes
|
||||
3. When user explicitly requests with the phrase **update memory bank** (MUST review ALL files)
|
||||
4. When context needs clarification
|
||||
|
||||
If I notice significant changes that should be preserved but the user hasn't explicitly requested an update, I should suggest: "Would you like me to update the memory bank to reflect these changes?"
|
||||
|
||||
To execute Memory Bank update, I will:
|
||||
|
||||
1. Review ALL project files
|
||||
2. Document current state
|
||||
3. Document Insights & Patterns
|
||||
4. If requested with additional context (e.g., "update memory bank using information from @/Makefile"), focus special attention on that source
|
||||
|
||||
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on context.md as it tracks current state.
|
||||
|
||||
### Add Task
|
||||
|
||||
When user completes a repetitive task (like adding support for a new model version) and wants to document it for future reference, they can request: **add task** or **store this as a task**.
|
||||
|
||||
This workflow is designed for repetitive tasks that follow similar patterns and require editing the same files. Examples include:
|
||||
- Adding support for new AI model versions
|
||||
- Implementing new API endpoints following established patterns
|
||||
- Adding new features that follow existing architecture
|
||||
|
||||
Tasks are stored in the file `tasks.md` in the memory bank folder. The file is optional and can be empty. The file can store many tasks.
|
||||
|
||||
To execute Add Task workflow:
|
||||
|
||||
1. Create or update `tasks.md` in the memory bank folder
|
||||
2. Document the task with:
|
||||
- Task name and description
|
||||
- Files that need to be modified
|
||||
- Step-by-step workflow followed
|
||||
- Important considerations or gotchas
|
||||
- Example of the completed implementation
|
||||
3. Include any context that was discovered during task execution but wasn't previously documented
|
||||
|
||||
Example task entry:
|
||||
```markdown
|
||||
## Add New Model Support
|
||||
**Last performed:** [date]
|
||||
**Files to modify:**
|
||||
- `/providers/gemini.md` - Add model to documentation
|
||||
- `/src/providers/gemini-config.ts` - Add model configuration
|
||||
- `/src/constants/models.ts` - Add to model list
|
||||
- `/tests/providers/gemini.test.ts` - Add test cases
|
||||
|
||||
**Steps:**
|
||||
1. Add model configuration with proper token limits
|
||||
2. Update documentation with model capabilities
|
||||
3. Add to constants file for UI display
|
||||
4. Write tests for new model configuration
|
||||
|
||||
**Important notes:**
|
||||
- Check Google's documentation for exact token limits
|
||||
- Ensure backward compatibility with existing configurations
|
||||
- Test with actual API calls before committing
|
||||
```
|
||||
|
||||
### Regular Task Execution
|
||||
|
||||
In the beginning of EVERY task I MUST read ALL memory bank files - this is not optional.
|
||||
|
||||
The memory bank files are located in `.kilocode/rules/memory-bank` folder. If the folder doesn't exist or is empty, I will warn user about potential issues with the memory bank. I will include `[Memory Bank: Active]` at the beginning of my response if I successfully read the memory bank files, or `[Memory Bank: Missing]` if the folder doesn't exist or is empty. If memory bank is missing, I will warn the user about potential issues and suggest initialization. I should briefly summarize my understanding of the project to confirm alignment with the user's expectations, like:
|
||||
|
||||
"[Memory Bank: Active] I understand we're building a React inventory system with barcode scanning. Currently implementing the scanner component that needs to work with the backend API."
|
||||
|
||||
When starting a task that matches a documented task in `tasks.md`, I should mention this and follow the documented workflow to ensure no steps are missed.
|
||||
|
||||
If the task was repetitive and might be needed again, I should suggest: "Would you like me to add this task to the memory bank for future reference?"
|
||||
|
||||
In the end of the task, when it seems to be completed, I will update `context.md` accordingly. If the change seems significant, I will suggest to the user: "Would you like me to update memory bank to reflect these changes?" I will not suggest updates for minor changes.
|
||||
|
||||
## Context Window Management
|
||||
|
||||
When the context window fills up during an extended session:
|
||||
1. I should suggest updating the memory bank to preserve the current state
|
||||
2. Recommend starting a fresh conversation/task
|
||||
3. In the new conversation, I will automatically load the memory bank files to maintain continuity
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
Memory Bank is built on Kilo Code's Custom Rules feature, with files stored as standard markdown documents that both the user and I can access.
|
||||
|
||||
## Important Notes
|
||||
|
||||
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
||||
|
||||
If I detect inconsistencies between memory bank files, I should prioritize brief.md and note any discrepancies to the user.
|
||||
|
||||
IMPORTANT: I MUST read ALL memory bank files at the start of EVERY task - this is not optional. The memory bank files are located in `.kilocode/rules/memory-bank` folder.
|
||||
304
.kilocode/rules/memory-bank/architecture.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# 系统架构
|
||||
|
||||
## 整体架构概览
|
||||
|
||||
Out Live 采用典型的 React Native 应用架构,基于 Expo Prebuild 构建原生 iOS 应用。应用采用分层架构设计,包含表现层、业务逻辑层、数据访问层和基础设施层。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[用户界面层] --> B[业务逻辑层]
|
||||
B --> C[数据访问层]
|
||||
C --> D[基础设施层]
|
||||
|
||||
A1[React Components] --> A
|
||||
A2[Expo Router] --> A
|
||||
A3[Liquid Glass UI] --> A
|
||||
|
||||
B1[Redux Store] --> B
|
||||
B2[Business Services] --> B
|
||||
B3[Hooks] --> B
|
||||
|
||||
C1[API Services] --> C
|
||||
C2[HealthKit Bridge] --> C
|
||||
C3[Local Storage] --> C
|
||||
|
||||
D1[Expo SDK] --> D
|
||||
D2[Native Modules] --> D
|
||||
D3[Third-party SDKs] --> D
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
digital-pilates/
|
||||
├── app/ # Expo Router 页面和路由
|
||||
│ ├── (tabs)/ # 标签页路由
|
||||
│ ├── auth/ # 认证相关页面
|
||||
│ ├── challenges/ # 挑战页面
|
||||
│ ├── fasting/ # 轻断食页面
|
||||
│ ├── food/ # 营养相关页面
|
||||
│ ├── profile/ # 用户资料页面
|
||||
│ ├── workout/ # 训练页面
|
||||
│ └── _layout.tsx # 根布局组件
|
||||
├── components/ # 可复用组件
|
||||
│ ├── ui/ # 基础 UI 组件
|
||||
│ ├── cards/ # 卡片组件
|
||||
│ └── forms/ # 表单组件
|
||||
├── services/ # 业务服务层
|
||||
│ ├── api.ts # API 基础服务
|
||||
│ ├── healthKit/ # HealthKit 集成
|
||||
│ ├── notifications/ # 通知服务
|
||||
│ └── aiCoach/ # AI 教练服务
|
||||
├── store/ # Redux 状态管理
|
||||
│ ├── slices/ # Redux Toolkit slices
|
||||
│ └── index.ts # Store 配置
|
||||
├── utils/ # 工具函数
|
||||
│ ├── health.ts # 健康数据处理
|
||||
│ ├── kvStore.ts # 本地存储
|
||||
│ └── notificationHelpers.ts
|
||||
├── constants/ # 常量定义
|
||||
│ ├── Colors.ts # 颜色主题
|
||||
│ ├── Routes.ts # 路由常量
|
||||
│ └── Api.ts # API 配置
|
||||
├── hooks/ # 自定义 Hooks
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── assets/ # 静态资源
|
||||
└── ios/ # iOS 原生代码
|
||||
```
|
||||
|
||||
## 核心架构组件
|
||||
|
||||
### 1. 路由架构 (Expo Router)
|
||||
|
||||
采用 Expo Router 6.0 实现文件系统路由:
|
||||
|
||||
- **标签页导航**: 5个主要标签页(健康、断食、习惯、挑战、个人)
|
||||
- **模态页面**: 认证、目标详情、设置等页面
|
||||
- **嵌套路由**: 支持复杂的页面层级结构
|
||||
- **类型安全**: 完全的 TypeScript 路由类型支持
|
||||
|
||||
### 2. 状态管理架构 (Redux Toolkit)
|
||||
|
||||
使用 Redux Toolkit 进行应用状态管理:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[UI Components] --> B[Redux Actions]
|
||||
B --> C[Redux Slices]
|
||||
C --> D[Redux Store]
|
||||
D --> A
|
||||
|
||||
E[Async Thunks] --> C
|
||||
F[Middleware] --> C
|
||||
G[Selectors] --> A
|
||||
```
|
||||
|
||||
**核心 Slices**:
|
||||
- `userSlice`: 用户信息和认证状态
|
||||
- `healthSlice`: 健康数据(步数、心率、HRV等)
|
||||
- `nutritionSlice`: 营养数据和饮食记录
|
||||
- `goalsSlice`: 目标和任务管理
|
||||
- `fastingSlice`: 轻断食状态和计划
|
||||
- `moodSlice`: 心情记录和分析
|
||||
- `workoutSlice`: 训练数据和计划
|
||||
|
||||
### 3. 组件架构
|
||||
|
||||
采用组件化设计,按功能域组织:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[页面组件] --> B[容器组件]
|
||||
B --> C[业务组件]
|
||||
C --> D[UI 组件]
|
||||
|
||||
E[Hooks] --> B
|
||||
F[Redux Selectors] --> B
|
||||
G[Services] --> C
|
||||
```
|
||||
|
||||
**组件层次**:
|
||||
- **页面组件**: 路由对应的顶级组件
|
||||
- **容器组件**: 处理数据获取和状态管理
|
||||
- **业务组件**: 封装特定业务逻辑的组件
|
||||
- **UI 组件**: 纯展示的可复用组件
|
||||
|
||||
### 4. 服务层架构
|
||||
|
||||
服务层负责业务逻辑和外部系统集成:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
A[Components] --> B[Service Layer]
|
||||
B --> C[API Services]
|
||||
B --> D[HealthKit Services]
|
||||
B --> E[Notification Services]
|
||||
B --> F[Storage Services]
|
||||
|
||||
C --> G[Remote API]
|
||||
D --> H[Native HealthKit]
|
||||
E --> I[Expo Notifications]
|
||||
F --> J[Local Storage]
|
||||
```
|
||||
|
||||
**核心服务**:
|
||||
- `api.ts`: RESTful API 客户端
|
||||
- `health.ts`: HealthKit 数据处理
|
||||
- `notifications.ts`: 通知管理
|
||||
- `aiCoach.ts`: AI 教练集成
|
||||
- `waterRecords.ts`: 饮水记录管理
|
||||
|
||||
### 5. 数据流架构
|
||||
|
||||
采用单向数据流设计:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[User Action] --> B[Component Event]
|
||||
B --> C[Redux Action]
|
||||
C --> D[Service Call]
|
||||
D --> E[API/HealthKit]
|
||||
E --> F[Data Update]
|
||||
F --> G[Redux State]
|
||||
G --> H[Component Re-render]
|
||||
```
|
||||
|
||||
## 关键设计模式
|
||||
|
||||
### 1. Repository 模式
|
||||
|
||||
数据访问层使用 Repository 模式抽象数据源:
|
||||
|
||||
```typescript
|
||||
// 示例:健康数据 Repository
|
||||
class HealthRepository {
|
||||
async getSteps(date: Date): Promise<number> {
|
||||
return fetchStepCount(date);
|
||||
}
|
||||
|
||||
async getHeartRate(date: Date): Promise<number | null> {
|
||||
return fetchHeartRate(date);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Observer 模式
|
||||
|
||||
通知系统使用观察者模式:
|
||||
|
||||
```typescript
|
||||
// 权限状态监听
|
||||
healthPermissionManager.on('permissionStatusChanged', (status) => {
|
||||
// 处理权限状态变化
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Strategy 模式
|
||||
|
||||
不同类型的数据处理使用策略模式:
|
||||
|
||||
```typescript
|
||||
// 营养数据计算策略
|
||||
interface NutritionStrategy {
|
||||
calculate(records: DietRecord[]): NutritionSummary;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Factory 模式
|
||||
|
||||
组件创建使用工厂模式:
|
||||
|
||||
```typescript
|
||||
// 卡片组件工厂
|
||||
const CardFactory = {
|
||||
createHealthCard: (props) => <HealthCard {...props} />,
|
||||
createNutritionCard: (props) => <NutritionCard {...props} />,
|
||||
};
|
||||
```
|
||||
|
||||
## 性能优化架构
|
||||
|
||||
### 1. 组件优化
|
||||
|
||||
- **React.memo**: 防止不必要的重渲染
|
||||
- **useMemo/useCallback**: 缓存计算结果和函数
|
||||
- **FlatList**: 大列表虚拟化
|
||||
- **InteractionManager**: 延迟非关键渲染
|
||||
|
||||
### 2. 数据优化
|
||||
|
||||
- **Redux Toolkit**: 自动化的状态优化
|
||||
- **数据分页**: 大数据集分页加载
|
||||
- **缓存策略**: 智能数据缓存
|
||||
- **后台同步**: 异步数据同步
|
||||
|
||||
### 3. 资源优化
|
||||
|
||||
- **图片优化**: WebP 格式和懒加载
|
||||
- **Bundle 分割**: 按需加载代码
|
||||
- **内存管理**: 及时释放资源
|
||||
- **网络优化**: 请求合并和缓存
|
||||
|
||||
## 安全架构
|
||||
|
||||
### 1. 数据安全
|
||||
|
||||
- **Token 管理**: JWT Token 安全存储
|
||||
- **API 加密**: HTTPS 通信
|
||||
- **数据脱敏**: 敏感数据处理
|
||||
- **权限控制**: 细粒度权限管理
|
||||
|
||||
### 2. 隐私保护
|
||||
|
||||
- **本地加密**: 敏感数据本地加密存储
|
||||
- **权限最小化**: 只请求必要的系统权限
|
||||
- **数据匿名化**: 统计数据匿名化处理
|
||||
- **用户控制**: 用户数据删除和导出
|
||||
|
||||
## 扩展性设计
|
||||
|
||||
### 1. 模块化架构
|
||||
|
||||
- **功能模块**: 独立的功能模块设计
|
||||
- **插件系统**: 支持功能插件扩展
|
||||
- **配置驱动**: 配置化的功能开关
|
||||
- **版本兼容**: 向后兼容的 API 设计
|
||||
|
||||
### 2. 多端支持
|
||||
|
||||
- **跨平台**: React Native 跨平台能力
|
||||
- **响应式**: 适配不同屏幕尺寸
|
||||
- **主题系统**: 可切换的主题设计
|
||||
- **国际化**: 多语言支持框架
|
||||
|
||||
## 监控和诊断
|
||||
|
||||
### 1. 错误监控
|
||||
|
||||
- **Sentry 集成**: 错误收集和分析
|
||||
- **崩溃报告**: 自动崩溃报告
|
||||
- **性能监控**: 应用性能指标
|
||||
- **用户反馈**: 内置反馈系统
|
||||
|
||||
### 2. 日志系统
|
||||
|
||||
- **分级日志**: 不同级别的日志记录
|
||||
- **结构化日志**: 便于分析的日志格式
|
||||
- **远程日志**: 日志远程收集
|
||||
- **隐私保护**: 敏感信息过滤
|
||||
|
||||
## 测试架构
|
||||
|
||||
### 1. 测试策略
|
||||
|
||||
- **单元测试**: 核心逻辑单元测试
|
||||
- **集成测试**: 组件集成测试
|
||||
- **端到端测试**: 关键流程 E2E 测试
|
||||
- **性能测试**: 性能基准测试
|
||||
|
||||
### 2. 测试工具
|
||||
|
||||
- **Jest**: 单元测试框架
|
||||
- **React Native Testing Library**: 组件测试
|
||||
- **Detox**: E2E 测试框架
|
||||
- **Flamegraph**: 性能分析工具
|
||||
1
.kilocode/rules/memory-bank/brief.md
Normal file
@@ -0,0 +1 @@
|
||||
一个 expo prebuild 之后的 react native 应用,只服务 ios 端,主要面向的是健康、减肥、瘦身、生活习惯养成的人群
|
||||
189
.kilocode/rules/memory-bank/context.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# 项目当前状态
|
||||
|
||||
## 应用基本信息
|
||||
|
||||
- **应用名称**: Out Live(超越生命)
|
||||
- **版本**: 1.0.19
|
||||
- **Bundle ID**: com.anonymous.digitalpilates
|
||||
- **平台**: iOS(仅支持 iOS,不支持 Android)
|
||||
- **最低支持版本**: iOS 16.0
|
||||
- **架构**: Expo Prebuild 后的 React Native 应用
|
||||
|
||||
## 当前开发状态
|
||||
|
||||
- **开发阶段**: 生产就绪版本
|
||||
- **最后更新**: 2025 年 11 月
|
||||
- **主要功能**: 已完成核心健康数据追踪、AI 教练、目标管理、轻断食等功能
|
||||
- **状态管理**: 使用 Redux Toolkit 进行状态管理
|
||||
- **数据存储**: 本地使用 expo-sqlite/kv-store,远程 API 集成
|
||||
|
||||
## 核心功能实现状态
|
||||
|
||||
### 健康数据追踪 ✅
|
||||
|
||||
- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据
|
||||
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
||||
- 实时健康数据监控和历史数据查看
|
||||
- 健康权限管理系统
|
||||
|
||||
### 营养管理 ✅
|
||||
|
||||
- 饮食记录功能(文字、语音、拍照识别)
|
||||
- 营养成分分析和卡路里计算
|
||||
- 食物库和自定义食物功能
|
||||
- 营养标签识别
|
||||
|
||||
### 目标与习惯管理 ✅
|
||||
|
||||
- 目标创建、编辑、删除功能
|
||||
- 任务分解和进度追踪
|
||||
- 智能提醒系统
|
||||
- 目标完成统计和分析
|
||||
|
||||
### 轻断食功能 ✅
|
||||
|
||||
- 多种预设断食方案(16:8、18:6 等)
|
||||
- 实时断食进度显示
|
||||
- 断食提醒和通知
|
||||
- 断食历史记录
|
||||
|
||||
### AI 教练系统 ✅
|
||||
|
||||
- AI 对话功能(流式响应)
|
||||
- 体态评估(照片分析)
|
||||
- 个性化健康建议
|
||||
- 情绪分析(基于 HRV)
|
||||
|
||||
### 社区与挑战 ✅
|
||||
|
||||
- 挑战赛参与和排行榜
|
||||
- 成就系统
|
||||
- 社交分享功能
|
||||
|
||||
### 训练计划 ✅
|
||||
|
||||
- 个性化训练计划生成
|
||||
- 运动库和动作指导
|
||||
- 训练进度记录
|
||||
|
||||
## 技术架构状态
|
||||
|
||||
### 前端架构 ✅
|
||||
|
||||
- React Native 0.81.4 + Expo 54
|
||||
- TypeScript 全面覆盖
|
||||
- Expo Router 6.0 用于路由管理
|
||||
- Redux Toolkit 用于状态管理
|
||||
- Liquid Glass 设计风格实现
|
||||
|
||||
### 后端集成 ✅
|
||||
|
||||
- RESTful API 集成(API 基础地址:https://pilate.richarjiang.com)
|
||||
- 用户认证和授权
|
||||
- 数据同步和备份
|
||||
- 推送通知服务
|
||||
|
||||
### 原生功能 ✅
|
||||
|
||||
- HealthKit 深度集成
|
||||
- 推送通知(本地和远程)
|
||||
- 快捷动作(Quick Actions)
|
||||
- 小组件支持
|
||||
- 后台任务管理
|
||||
|
||||
## 当前开发重点
|
||||
|
||||
### 近期更新
|
||||
|
||||
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
||||
2. **性能优化**: 优化健康数据加载和图表渲染性能
|
||||
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
||||
4. **数据同步**: 增强离线功能和数据同步稳定性
|
||||
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
||||
|
||||
### 待解决问题
|
||||
|
||||
1. **多语言覆盖**: 其他页面的多语言翻译支持需要逐步完善
|
||||
2. **测试覆盖**: 自动化测试覆盖率需要提升
|
||||
3. **错误监控**: 需要集成更完善的错误监控和分析
|
||||
4. **性能监控**: 应用性能监控和分析工具集成
|
||||
5. **文档完善**: API 文档和组件文档需要进一步完善
|
||||
|
||||
## 代码质量状态
|
||||
|
||||
### 代码规范 ✅
|
||||
|
||||
- ESLint 配置完善(eslint-config-expo)
|
||||
- Prettier 代码格式化
|
||||
- TypeScript 严格模式
|
||||
- 组件和函数命名规范
|
||||
|
||||
### 项目结构 ✅
|
||||
|
||||
- 清晰的目录结构(app/、components/、services/、store/、utils/)
|
||||
- 功能模块化组织
|
||||
- 类型定义完整
|
||||
- 常量和配置集中管理
|
||||
|
||||
### 状态管理 ✅
|
||||
|
||||
- Redux Toolkit 标准实现
|
||||
- 异步操作处理规范
|
||||
- 数据持久化策略
|
||||
- 错误处理机制
|
||||
|
||||
## 部署和发布
|
||||
|
||||
### 构建配置 ✅
|
||||
|
||||
- Expo Prebuild 配置
|
||||
- iOS 证书和配置文件
|
||||
- App Store 发布配置
|
||||
- 自动化构建流程
|
||||
|
||||
### 发布状态 ✅
|
||||
|
||||
- App Store 已发布版本
|
||||
- 支持 OTA 更新
|
||||
- 崩溃监控和分析
|
||||
- 用户反馈收集
|
||||
|
||||
## 团队协作
|
||||
|
||||
### 开发工具 ✅
|
||||
|
||||
- Git 版本控制
|
||||
- VS Code 开发环境
|
||||
- Expo 开发者工具
|
||||
- iOS 模拟器和真机调试
|
||||
|
||||
### 文档状态 🔄
|
||||
|
||||
- API 文档部分完成
|
||||
- 组件文档需要补充
|
||||
- 部署文档完善
|
||||
- 开发指南需要更新
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 短期目标(1-2 个月)
|
||||
|
||||
1. 完善所有核心页面的多语言翻译支持
|
||||
2. 完善自动化测试覆盖
|
||||
3. 优化应用启动性能
|
||||
4. 增强错误监控和分析
|
||||
5. 改进用户引导流程
|
||||
|
||||
### 中期目标(3-6 个月)
|
||||
|
||||
1. 扩展 AI 教练功能
|
||||
2. 增加更多健康指标追踪
|
||||
3. 优化数据同步策略
|
||||
4. 增强社交功能
|
||||
|
||||
### 长期目标(6 个月以上)
|
||||
|
||||
1. 支持 Apple Watch 应用
|
||||
2. 集成更多第三方健康设备
|
||||
3. 开发 Web 端管理界面
|
||||
4. 扩展企业健康解决方案
|
||||
90
.kilocode/rules/memory-bank/product.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 产品概述
|
||||
|
||||
## 产品定位
|
||||
Out Live(超越生命)是一款专注于健康、减肥、瘦身和生活习惯养成的 iOS 应用。该应用通过整合健康数据追踪、AI 教练指导、目标管理和社区挑战等功能,为用户提供全方位的健康生活管理解决方案。
|
||||
|
||||
## 目标用户
|
||||
- 关注健康和体重管理的用户
|
||||
- 希望养成良好生活习惯的用户
|
||||
- 对普拉提和健身感兴趣的用户
|
||||
- 需要健康数据追踪和分析的用户
|
||||
- 希望通过 AI 获得个性化健康指导的用户
|
||||
|
||||
## 核心价值主张
|
||||
1. **全方位健康数据管理**:整合 HealthKit 数据,提供步数、心率、睡眠、饮水量等多维度健康指标追踪
|
||||
2. **AI 智能教练**:基于用户健康数据提供个性化的健康建议和指导
|
||||
3. **目标管理系统**:帮助用户设定、追踪和完成健康目标
|
||||
4. **社区挑战激励**:通过挑战赛和排行榜增强用户参与感和成就感
|
||||
5. **轻断食指导**:提供科学的轻断食计划和提醒功能
|
||||
|
||||
## 主要功能模块
|
||||
|
||||
### 健康数据追踪
|
||||
- **活动圆环**:展示活动卡路里、锻炼分钟和站立小时
|
||||
- **步数统计**:按小时显示步数数据和趋势
|
||||
- **心率监测**:实时心率和心率变异性(HRV)分析
|
||||
- **睡眠分析**:睡眠质量和时长追踪
|
||||
- **体重管理**:体重记录和 BMI 计算
|
||||
- **饮水量追踪**:每日饮水目标设定和记录
|
||||
|
||||
### 营养管理
|
||||
- **饮食记录**:支持文字、语音和拍照识别食物
|
||||
- **营养分析**:卡路里、蛋白质、碳水化合物等营养成分分析
|
||||
- **食物库**:丰富的食物数据库和自定义食物功能
|
||||
- **营养标签识别**:通过拍照识别食品营养标签
|
||||
|
||||
### 目标与习惯管理
|
||||
- **目标设定**:支持日、周、月重复模式的目标设定
|
||||
- **任务管理**:将目标分解为可执行的任务
|
||||
- **进度追踪**:可视化目标完成进度
|
||||
- **提醒功能**:智能提醒帮助用户坚持目标
|
||||
|
||||
### 轻断食功能
|
||||
- **断食计划**:多种预设断食方案(16:8、18:6等)
|
||||
- **断食追踪**:实时显示断食进度和状态
|
||||
- **智能提醒**:断食开始和结束提醒
|
||||
- **断食历史**:记录和分析断食历史数据
|
||||
|
||||
### AI 教练系统
|
||||
- **智能对话**:基于用户健康数据提供个性化建议
|
||||
- **体态评估**:通过 AI 分析用户体态照片
|
||||
- **健康指导**:提供运动、营养和生活方式建议
|
||||
- **情绪分析**:基于 HRV 数据分析压力水平
|
||||
|
||||
### 社区与挑战
|
||||
- **挑战赛**:参与各种健康主题挑战
|
||||
- **排行榜**:与好友或其他用户比较进度
|
||||
- **成就系统**:完成目标获得成就奖励
|
||||
- **社交分享**:分享健康成果到社交平台
|
||||
|
||||
### 训练计划
|
||||
- **个性化计划**:基于用户目标生成训练计划
|
||||
- **运动库**:丰富的运动动作库和指导
|
||||
- **进度追踪**:记录训练完成情况和效果
|
||||
- **智能推荐**:根据用户表现调整训练计划
|
||||
|
||||
## 用户体验特色
|
||||
1. **Liquid Glass 设计风格**:采用现代化的毛玻璃效果设计
|
||||
2. **数据可视化**:丰富的图表和动画展示健康数据
|
||||
3. **快捷操作**:支持快捷动作和小组件快速记录
|
||||
4. **离线功能**:核心功能支持离线使用
|
||||
5. **隐私保护**:严格保护用户健康数据隐私
|
||||
|
||||
## 技术亮点
|
||||
- **HealthKit 深度集成**:充分利用 iOS 健康生态系统
|
||||
- **实时数据同步**:支持多设备数据实时同步
|
||||
- **智能通知系统**:基于用户行为的智能提醒
|
||||
- **性能优化**:针对大量健康数据的性能优化
|
||||
- **无障碍支持**:完整的无障碍功能支持
|
||||
|
||||
## 商业模式
|
||||
- **免费增值模式**:基础功能免费,高级功能付费
|
||||
- **VIP 会员**:提供更多个性化功能和专业指导
|
||||
- **企业健康**:面向企业提供的员工健康管理解决方案
|
||||
|
||||
## 竞争优势
|
||||
1. **全平台整合**:深度整合 iOS 健康生态系统
|
||||
2. **AI 技术应用**:先进的 AI 分析和个性化推荐
|
||||
3. **用户体验**:优秀的界面设计和交互体验
|
||||
4. **数据安全**:严格的数据隐私保护措施
|
||||
5. **专业内容**:基于科学研究的健康指导内容
|
||||
753
.kilocode/rules/memory-bank/tasks.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# 常见任务和模式
|
||||
|
||||
## 图标库使用规范 - 禁止使用 MaterialIcons
|
||||
|
||||
**最后更新**: 2025-10-24
|
||||
|
||||
### 重要规则
|
||||
|
||||
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
|
||||
|
||||
### 问题描述
|
||||
|
||||
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons,以保持图标库的一致性。
|
||||
|
||||
### 解决方案
|
||||
|
||||
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 替换导入语句
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
|
||||
// ✅ 正确写法
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
```
|
||||
|
||||
#### 2. 替换图标名称和属性
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
|
||||
|
||||
// ✅ 正确写法 - 使用 HeaderBar 中的返回按钮实现
|
||||
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||
```
|
||||
|
||||
#### 3. 常见图标映射
|
||||
|
||||
- `arrow-back-ios` → `chevron-back` (返回按钮)
|
||||
- `auto-awesome` → `star` (星星/自动推荐)
|
||||
- `tips-and-updates` → `bulb` (提示/建议)
|
||||
- `fact-check` → `checkbox` (检查/确认)
|
||||
- `check-circle` → `checkmark-circle` (勾选圆圈)
|
||||
- `remove` → `remove` (移除/删除,名称相同)
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **图标大小调整**:Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
|
||||
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
|
||||
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
|
||||
4. **Liquid Glass 兼容性**:替换后的图标需要继续支持 Liquid Glass 效果
|
||||
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
|
||||
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
|
||||
|
||||
## 按钮组件 Liquid Glass 兼容性
|
||||
|
||||
**最后更新**: 2025-10-24
|
||||
|
||||
### 重要原则
|
||||
|
||||
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件
|
||||
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.button}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="icon-name" size={20} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.button, styles.fallbackButton]}>
|
||||
<Ionicons name="icon-name" size={20} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20, // 圆形按钮
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||
// 其他通用样式...
|
||||
},
|
||||
fallbackButton: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
4. **交互反馈**:设置 `isInteractive={true}` 启用原生的触觉反馈
|
||||
5. **图标居中**:确保使用 `alignItems: 'center'` 和 `justifyContent: 'center'` 使图标完全居中
|
||||
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
|
||||
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
||||
- 确认操作:绿色系 `rgba(76, 175, 80, 0.2)`
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
|
||||
## HeaderBar 顶部距离处理
|
||||
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useSafeAreaTop } from "@/hooks/useSafeAreaWithPadding";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取 safeAreaTop
|
||||
|
||||
```typescript
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
```
|
||||
|
||||
#### 3. 应用到内容容器
|
||||
|
||||
```typescript
|
||||
// 方式1: 直接应用到 View 组件
|
||||
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
||||
|
||||
// 方式2: 应用到 ScrollView 的 contentContainerStyle
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingTop: safeAreaTop }}
|
||||
>
|
||||
|
||||
// 方式3: 应用到 SectionList 的 style
|
||||
<SectionList
|
||||
style={{ paddingTop: safeAreaTop }}
|
||||
>
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
|
||||
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
|
||||
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
|
||||
|
||||
### 示例代码
|
||||
|
||||
```typescript
|
||||
// ❌ 错误写法 - 在 StyleSheet 中使用变量
|
||||
const styles = StyleSheet.create({
|
||||
filterContainer: {
|
||||
paddingTop: safeAreaTop, // 这会导致错误
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ 正确写法 - 使用动态样式
|
||||
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
||||
```
|
||||
|
||||
### 参考页面
|
||||
|
||||
- `app/steps/detail.tsx`
|
||||
- `app/water/detail.tsx`
|
||||
- `app/profile/goals.tsx`
|
||||
- `app/workout/history.tsx`
|
||||
- `app/challenges/[id]/leaderboard.tsx`
|
||||
|
||||
## Liquid Glass 风格图标按钮实现
|
||||
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件和函数
|
||||
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.glassButton, styles.fallbackButton]}>
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
glassButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18, // 圆形按钮
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||
},
|
||||
fallbackButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(244, 67, 54, 0.3)",
|
||||
backgroundColor: "rgba(244, 67, 54, 0.1)",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
4. **交互反馈**:设置 `isInteractive={true}` 启用原生的触觉反馈
|
||||
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
||||
- 确认操作:绿色系 `rgba(76, 175, 80, 0.2)`
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
|
||||
## 登录验证实现模式
|
||||
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
|
||||
|
||||
### 权限校验原则
|
||||
|
||||
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useAuthGuard } from "@/hooks/useAuthGuard";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取方法
|
||||
|
||||
```typescript
|
||||
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 3. 替换导航操作
|
||||
|
||||
```typescript
|
||||
// ❌ 原来的写法 - 没有登录验证
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/food/nutrition-analysis-history')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
||||
// ✅ 修改后的写法 - 带登录验证
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
```
|
||||
|
||||
#### 4. 服务端接口调用的登录验证
|
||||
|
||||
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
|
||||
|
||||
```typescript
|
||||
// ❌ 原来的写法 - 没有登录验证
|
||||
<TouchableOpacity
|
||||
onPress={() => startNewAnalysis(imageUri)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
|
||||
// ✅ 修改后的写法 - 带登录验证
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
// 先验证登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (isLoggedIn) {
|
||||
startNewAnalysis(imageUri);
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
```
|
||||
|
||||
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
|
||||
|
||||
```typescript
|
||||
{
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.historyButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||
style={[styles.historyButton, styles.fallbackBackground]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
|
||||
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
|
||||
3. **登录重定向**:登录页面会接收 `redirectTo` 和 `redirectParams` 参数用于登录后跳转
|
||||
4. **兼容性**:与 Liquid Glass 设计风格完全兼容,可以同时使用
|
||||
5. **服务端接口调用**:所有调用服务端接口的功能必须使用 `ensureLoggedIn` 进行登录验证
|
||||
6. **异步处理**:`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
|
||||
|
||||
### 其他可用方法
|
||||
|
||||
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
|
||||
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
|
||||
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
|
||||
|
||||
### 使用场景选择
|
||||
|
||||
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
|
||||
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
|
||||
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
|
||||
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
|
||||
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
|
||||
|
||||
## 路由常量管理
|
||||
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
|
||||
|
||||
### 解决方案
|
||||
|
||||
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 添加新路由常量
|
||||
|
||||
在 `constants/Routes.ts` 文件中添加新的路由常量:
|
||||
|
||||
```typescript
|
||||
export const ROUTES = {
|
||||
// 现有路由...
|
||||
|
||||
// 新增路由
|
||||
FOOD_CAMERA: "/food/camera",
|
||||
} as const;
|
||||
```
|
||||
|
||||
#### 2. 在组件中使用路由常量
|
||||
|
||||
导入并使用路由常量,而不是硬编码路径:
|
||||
|
||||
```typescript
|
||||
import { ROUTES } from "@/constants/Routes";
|
||||
|
||||
// ❌ 错误写法 - 硬编码路径
|
||||
router.push("/food/camera?mealType=dinner");
|
||||
|
||||
// ✅ 正确写法 - 使用路由常量
|
||||
router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`);
|
||||
```
|
||||
|
||||
#### 3. 结合登录验证使用
|
||||
|
||||
对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用:
|
||||
|
||||
```typescript
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
|
||||
// 在需要登录验证的路由中使用
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin(`${ROUTES.FOOD_CAMERA}?mealType=${currentMealType}`)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义
|
||||
2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA`
|
||||
3. **路径一致性**:常量名应该清晰表达路由的用途
|
||||
4. **参数处理**:查询参数和路径参数在使用时动态拼接
|
||||
5. **类型安全**:使用 `as const` 确保类型推导
|
||||
|
||||
### 路由分类
|
||||
|
||||
按照功能模块对路由进行分组:
|
||||
|
||||
```typescript
|
||||
export const ROUTES = {
|
||||
// Tab路由
|
||||
TAB_EXPLORE: "/explore",
|
||||
TAB_COACH: "/coach",
|
||||
|
||||
// 营养相关路由
|
||||
NUTRITION_RECORDS: "/nutrition/records",
|
||||
FOOD_LIBRARY: "/food-library",
|
||||
FOOD_CAMERA: "/food/camera",
|
||||
|
||||
// 用户相关路由
|
||||
AUTH_LOGIN: "/auth/login",
|
||||
PROFILE_EDIT: "/profile/edit",
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `constants/Routes.ts` - 路由常量定义
|
||||
- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证
|
||||
- `app/food/camera.tsx` - 食物拍照页面实现
|
||||
|
||||
## 多语言翻译实现规范
|
||||
|
||||
**最后更新**: 2025-11-26
|
||||
|
||||
### 重要原则
|
||||
|
||||
**所有用户可见的文本都必须支持多语言翻译**,这是项目的基本要求。不允许在代码中硬编码任何用户可见的中文或英文文本。
|
||||
|
||||
### 问题描述
|
||||
|
||||
在开发新功能或修改现有功能时,所有用户界面文本都需要支持多语言切换,确保应用能够为不同语言用户提供本地化体验。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用项目集成的 i18next 翻译系统,在 `i18n/index.ts` 中定义翻译资源,在组件中使用 `useI18n` hook 获取翻译文本。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取翻译函数
|
||||
|
||||
```typescript
|
||||
const { t } = useI18n();
|
||||
```
|
||||
|
||||
#### 3. 添加翻译资源
|
||||
|
||||
在 `i18n/index.ts` 中为新的功能模块添加翻译资源:
|
||||
|
||||
```typescript
|
||||
// 中文翻译
|
||||
const newFeatureResources = {
|
||||
title: "新功能标题",
|
||||
subtitle: "新功能描述",
|
||||
button: "按钮文本",
|
||||
loading: "加载中...",
|
||||
error: "操作失败,请稍后重试",
|
||||
success: "操作成功",
|
||||
};
|
||||
|
||||
// 英文翻译
|
||||
const newFeatureResourcesEn = {
|
||||
title: "New Feature Title",
|
||||
subtitle: "New feature description",
|
||||
button: "Button Text",
|
||||
loading: "Loading...",
|
||||
error: "Operation failed, please try again later",
|
||||
success: "Operation successful",
|
||||
};
|
||||
|
||||
// 添加到资源对象中
|
||||
resources = {
|
||||
zh: {
|
||||
translation: {
|
||||
// 现有翻译...
|
||||
newFeature: newFeatureResources,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
translation: {
|
||||
// 现有翻译...
|
||||
newFeature: newFeatureResourcesEn,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 在组件中使用翻译
|
||||
|
||||
```typescript
|
||||
// ❌ 错误写法 - 硬编码文本
|
||||
<Text>加载中...</Text>
|
||||
<Text>操作失败,请稍后重试</Text>
|
||||
|
||||
// ✅ 正确写法 - 使用翻译函数
|
||||
<Text>{t('newFeature.loading')}</Text>
|
||||
<Text>{t('newFeature.error')}</Text>
|
||||
```
|
||||
|
||||
#### 5. 动态参数翻译
|
||||
|
||||
对于包含动态参数的文本,使用插值语法:
|
||||
|
||||
```typescript
|
||||
// 翻译资源中
|
||||
welcome: '欢迎,{{name}}!'
|
||||
itemsCount: '共 {{count}} 个项目'
|
||||
|
||||
// 组件中使用
|
||||
<Text>{t('newFeature.welcome', { name: userName })}</Text>
|
||||
<Text>{t('newFeature.itemsCount', { count: items.length })}</Text>
|
||||
```
|
||||
|
||||
#### 6. 嵌套翻译键
|
||||
|
||||
对于复杂功能,使用嵌套的翻译键结构:
|
||||
|
||||
```typescript
|
||||
// 翻译资源
|
||||
modal: {
|
||||
title: '确认操作',
|
||||
description: '确定要执行此操作吗?',
|
||||
buttons: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
},
|
||||
}
|
||||
|
||||
// 组件中使用
|
||||
<Text>{t('newFeature.modal.title')}</Text>
|
||||
<Text>{t('newFeature.modal.buttons.confirm')}</Text>
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **禁止硬编码**:所有用户可见的文本都必须通过翻译函数获取
|
||||
2. **完整翻译**:中文和英文翻译都必须提供,保持翻译完整性
|
||||
3. **语义化命名**:翻译键应该清晰表达文本的用途和含义
|
||||
4. **参数化文本**:包含动态内容的文本应该使用插值参数
|
||||
5. **一致性**:相同功能的文本应该使用相同的翻译键
|
||||
6. **Toast 消息**:Toast 提示消息也需要翻译支持
|
||||
7. **错误消息**:错误提示信息必须支持多语言
|
||||
8. **表单验证**:表单验证错误信息需要翻译
|
||||
|
||||
### 常见翻译模式
|
||||
|
||||
#### 1. 状态文本
|
||||
|
||||
```typescript
|
||||
status: {
|
||||
loading: '加载中...',
|
||||
success: '操作成功',
|
||||
error: '操作失败',
|
||||
empty: '暂无数据',
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 按钮文本
|
||||
|
||||
```typescript
|
||||
buttons: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
add: '添加',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 表单相关
|
||||
|
||||
```typescript
|
||||
form: {
|
||||
placeholders: {
|
||||
email: '请输入邮箱地址',
|
||||
password: '请输入密码',
|
||||
},
|
||||
errors: {
|
||||
required: '此字段为必填项',
|
||||
invalid: '格式不正确',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 列表和表格
|
||||
|
||||
```typescript
|
||||
list: {
|
||||
empty: '暂无数据',
|
||||
loading: '加载中...',
|
||||
loadMore: '加载更多',
|
||||
refresh: '刷新',
|
||||
}
|
||||
```
|
||||
|
||||
### 翻译键命名规范
|
||||
|
||||
1. **使用小写字母和点号分隔**:`feature.section.item`
|
||||
2. **按功能模块分组**:`challenges.title`, `challenges.subtitle`
|
||||
3. **语义化命名**:`buttons.confirm`, `errors.network`
|
||||
4. **避免缩写**:使用 `description` 而不是 `desc`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/(tabs)/challenges.tsx` - 完整的多语言翻译实现示例
|
||||
- `i18n/index.ts` - 翻译资源配置
|
||||
- `hooks/useI18n.ts` - 翻译 hook 实现
|
||||
- `app/(tabs)/personal.tsx` - 个人中心页面翻译实现
|
||||
- `app/food/nutrition-label-analysis.tsx` - 营养分析页面翻译实现
|
||||
|
||||
### 检查清单
|
||||
|
||||
在开发新功能时,请确保:
|
||||
|
||||
- [ ] 所有用户可见的文本都使用了翻译函数
|
||||
- [ ] 在 `i18n/index.ts` 中添加了对应的中文和英文翻译
|
||||
- [ ] Toast 消息支持多语言
|
||||
- [ ] 错误提示信息支持多语言
|
||||
- [ ] 表单验证错误信息支持多语言
|
||||
- [ ] 动态参数文本使用了插值语法
|
||||
- [ ] 翻译键命名符合规范
|
||||
- [ ] 测试了语言切换功能
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **开发时即考虑多语言**:在编写组件时就使用翻译函数,而不是事后添加
|
||||
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
|
||||
3. **定期审查**:定期检查是否有硬编码文本遗漏
|
||||
4. **测试验证**:在开发完成后测试语言切换功能是否正常
|
||||
227
.kilocode/rules/memory-bank/tech.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 技术栈
|
||||
|
||||
## 核心技术
|
||||
|
||||
### 前端框架
|
||||
- **React Native**: 0.81.4 - 跨平台移动应用开发框架
|
||||
- **Expo SDK**: 54.0.13 - React Native 开发平台和工具链
|
||||
- **Expo Router**: 6.0.12 - 基于文件系统的路由库
|
||||
- **TypeScript**: 5.9.2 - 类型安全的 JavaScript 超集
|
||||
|
||||
### 状态管理
|
||||
- **Redux Toolkit**: 2.9.0 - 状态管理解决方案
|
||||
- **React Redux**: 9.2.0 - React Redux 绑定
|
||||
- **Redux Listener Middleware**: 自定义中间件用于自动同步
|
||||
|
||||
### UI 框架和样式
|
||||
- **React Native Elements**: UI 组件库
|
||||
- **Expo UI**: 0.2.0-beta.7 - Expo UI 组件
|
||||
- **Expo Glass Effect**: 0.1.4 - Liquid Glass 毛玻璃效果, 优先使用
|
||||
- **React Native Reanimated**: 4.1.0 - 高性能动画库
|
||||
- **React Native Gesture Handler**: 2.28.0 - 手势处理
|
||||
- **React Native SVG**: 15.12.1 - SVG 图形支持
|
||||
|
||||
### 导航
|
||||
- **Expo Router**: 6.0.12 - 文件系统路由
|
||||
- **React Navigation**: 7.x - 导航库
|
||||
|
||||
## 数据和存储
|
||||
|
||||
### 本地存储
|
||||
- **Expo SQLite**: 16.0.8 - SQLite 数据库
|
||||
- **Expo SQLite KV Store**: 键值存储
|
||||
- **Async Storage**: 2.2.0 - 异步存储(兼容层)
|
||||
|
||||
### 网络和 API
|
||||
- **Fetch API**: 原生网络请求
|
||||
- **XMLHttpRequest**: 流式请求支持
|
||||
- **Axios**: HTTP 客户端(可选)
|
||||
|
||||
## 原生功能集成
|
||||
|
||||
### HealthKit 集成
|
||||
- **自定义 HealthKit Manager**: iOS 原生模块
|
||||
- **健康数据类型**: 步数、心率、HRV、睡眠、活动圆环等
|
||||
- **权限管理**: 动态权限请求和状态监控
|
||||
|
||||
### 通知系统
|
||||
- **Expo Notifications**: 0.32.12 - 本地和推送通知
|
||||
- **后台任务**: Expo Task Manager
|
||||
- **推送通知**: 远程推送支持
|
||||
|
||||
### 设备功能
|
||||
- **Expo Camera**: 17.0.8 - 相机功能
|
||||
- **Expo Image Picker**: 17.0.8 - 图片选择
|
||||
- **Expo Haptics**: 15.0.7 - 触觉反馈
|
||||
- **Expo Quick Actions**: 6.0.0 - 快捷动作
|
||||
- **Expo Symbols**: 1.0.7 - SF Symbols
|
||||
|
||||
## 开发工具和构建
|
||||
|
||||
### 构建系统
|
||||
- **Expo Prebuild**: 原生构建生成
|
||||
- **Metro**: JavaScript 打包工具
|
||||
- **Babel**: JavaScript 编译器
|
||||
|
||||
### 代码质量
|
||||
- **ESLint**: 9.35.0 - 代码检查
|
||||
- **ESLint Config Expo**: 10.0.0 - Expo ESLint 配置
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型检查
|
||||
|
||||
### 开发环境
|
||||
- **VS Code**: 主要开发 IDE
|
||||
- **Expo Go**: 开发调试
|
||||
- **iOS Simulator**: iOS 模拟器
|
||||
- **Xcode**: iOS 原生开发
|
||||
|
||||
## 第三方服务
|
||||
|
||||
### 云存储
|
||||
- **腾讯云 COS**: 图片和文件存储
|
||||
- **上传服务**: 自定义上传实现
|
||||
|
||||
### AI 服务
|
||||
- **AI 教练**: 自定义 AI 对话服务
|
||||
- **图像识别**: 食物识别
|
||||
- **语音识别**: 语音转文字
|
||||
|
||||
### 分析和监控
|
||||
- **Sentry**: 7.2.0 - 错误监控和性能分析
|
||||
- **崩溃报告**: 自动崩溃收集
|
||||
|
||||
## UI 组件库
|
||||
|
||||
### 基础组件
|
||||
- **ThemedView**: 主题化视图组件
|
||||
- **ThemedText**: 主题化文本组件
|
||||
- **IconSymbol**: 图标组件
|
||||
- **ProgressBar**: 进度条组件
|
||||
- **AnimatedNumber**: 数字动画组件
|
||||
|
||||
### 业务组件
|
||||
- **FitnessRingsCard**: 健身圆环卡片
|
||||
- **StepsCard**: 步数卡片
|
||||
- **NutritionRadarCard**: 营养雷达图
|
||||
- **WaterIntakeCard**: 饮水记录卡片
|
||||
- **MoodCard**: 心情卡片
|
||||
- **GoalCard**: 目标卡片
|
||||
- **TaskCard**: 任务卡片
|
||||
|
||||
### 图表组件
|
||||
- **RadarChart**: 雷达图
|
||||
- **CircularRing**: 圆形进度环
|
||||
- **CalorieRingChart**: 卡路里环形图
|
||||
- **ActivityHeatMap**: 活动热力图
|
||||
|
||||
## 开发依赖
|
||||
|
||||
### 类型定义
|
||||
- **React Types**: 19.1.13
|
||||
- **React Native Types**: 内置
|
||||
- **Expo Types**: 内置
|
||||
|
||||
### 工具库
|
||||
- **Day.js**: 1.11.18 - 日期处理
|
||||
- **Lodash**: 4.17.21 - 工具函数库
|
||||
- **React Native Chart Kit**: 6.12.0 - 图表库
|
||||
- **Lottie React Native**: 7.3.4 - 动画库
|
||||
|
||||
### 音频和媒体
|
||||
- **React Native Voice**: 3.2.4 - 语音识别
|
||||
- **Expo Media Library**: 18.2.0 - 媒体库
|
||||
- **Expo Audio**: 音频处理
|
||||
|
||||
## 平台特定配置
|
||||
|
||||
### iOS 配置
|
||||
- **最低版本**: iOS 16.0
|
||||
- **Bundle ID**: com.anonymous.digitalpilates
|
||||
- **Team ID**: 756WVXJ6MT
|
||||
- **权限配置**: 相机、相册、麦克风、健康数据、通知等
|
||||
|
||||
### 构建配置
|
||||
- **New Arch**: 启用
|
||||
- **JS Engine**: JSC
|
||||
- **Metro 配置**: 自定义配置
|
||||
- **插件配置**: 多个 Expo 插件
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 渲染优化
|
||||
- **React.memo**: 组件记忆化
|
||||
- **useMemo/useCallback**: 钩子优化
|
||||
- **FlatList**: 大列表优化
|
||||
- **InteractionManager**: 延迟渲染
|
||||
|
||||
### 数据优化
|
||||
- **Redux Toolkit**: 自动优化
|
||||
- **数据分页**: 分页加载
|
||||
- **缓存策略**: 智能缓存
|
||||
- **后台同步**: 异步同步
|
||||
|
||||
### 资源优化
|
||||
- **图片优化**: WebP 格式
|
||||
- **Bundle 分割**: 代码分割
|
||||
- **内存管理**: 资源释放
|
||||
- **网络优化**: 请求合并
|
||||
|
||||
## 安全措施
|
||||
|
||||
### 数据安全
|
||||
- **HTTPS**: 加密通信
|
||||
- **Token 管理**: JWT 存储
|
||||
- **数据加密**: 本地加密
|
||||
- **权限控制**: 细粒度权限
|
||||
|
||||
### 隐私保护
|
||||
- **数据脱敏**: 敏感数据处理
|
||||
- **权限最小化**: 最小权限原则
|
||||
- **用户控制**: 数据控制权
|
||||
- **合规性**: 隐私法规遵循
|
||||
|
||||
## 测试框架
|
||||
|
||||
### 单元测试
|
||||
- **Jest**: 测试框架
|
||||
- **React Native Testing Library**: 组件测试
|
||||
- **Mock**: 模拟数据和服务
|
||||
|
||||
### 集成测试
|
||||
- **Detox**: E2E 测试(可选)
|
||||
- **手动测试**: 功能验证
|
||||
- **性能测试**: 性能基准
|
||||
|
||||
## 部署和发布
|
||||
|
||||
### 构建流程
|
||||
- **Expo EAS Build**: 云端构建
|
||||
- **App Store Connect**: 应用商店发布
|
||||
- **OTA 更新**: 热更新
|
||||
- **版本管理**: 语义化版本
|
||||
|
||||
### 持续集成
|
||||
- **GitHub Actions**: 自动化流程
|
||||
- **代码检查**: 自动化检查
|
||||
- **测试执行**: 自动化测试
|
||||
- **部署流程**: 自动化部署
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码规范
|
||||
- **ESLint**: 代码检查
|
||||
- **Prettier**: 代码格式化
|
||||
- **TypeScript**: 类型安全
|
||||
- **命名规范**: 统一命名
|
||||
|
||||
### Git 工作流
|
||||
- **Conventional Commits**: 提交规范
|
||||
- **分支策略**: Git Flow
|
||||
- **代码审查**: PR 流程
|
||||
- **版本标签**: 标签管理
|
||||
|
||||
### 文档规范
|
||||
- **JSDoc**: 代码注释
|
||||
- **README**: 项目文档
|
||||
- **API 文档**: 接口文档
|
||||
- **组件文档**: 组件说明
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 中文回复规则
|
||||
|
||||
请始终使用中文进行回复和编写文档。包括:
|
||||
|
||||
- 所有对话回复都使用中文
|
||||
- 代码注释使用中文
|
||||
- 文档和说明使用中文
|
||||
- 错误信息和提示使用中文
|
||||
- 变量名和函数名可以使用英文,但注释和文档说明必须是中文
|
||||
|
||||
这个规则适用于所有交互,除非用户明确要求使用其他语言。
|
||||
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.
|
||||
147
CLAUDE.md
@@ -3,39 +3,122 @@
|
||||
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`
|
||||
|
||||
### Development
|
||||
- **`npm run ios`** - Build and run on iOS Simulator (iOS-only deployment)
|
||||
|
||||
### Testing
|
||||
- Automated testing is minimal; complex logic should include Jest + React Native Testing Library specs under `__tests__/` directories or alongside modules
|
||||
|
||||
## Architecture
|
||||
- **Framework**: React Native (Expo) with TypeScript.
|
||||
- **Navigation**: Expo Router for file-based routing (`app/` directory).
|
||||
- **State Management**: Redux Toolkit with slices for different domains (user, training plans, workouts, challenges, etc.).
|
||||
- **UI**: Themed components (`ThemedText`, `ThemedView`) and reusable UI elements (`Collapsible`, `ParallaxScrollView`).
|
||||
- **API Layer**: Service files for communicating with backend APIs (`services/` directory).
|
||||
- **Data Persistence**: AsyncStorage for local data storage.
|
||||
- **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules.
|
||||
- **Hooks**: Custom hooks for color scheme (`useColorScheme`), theme management (`useThemeColor`), and Redux integration (`useRedux`).
|
||||
- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, image picking, etc.), and third-party libraries for specific functionality.
|
||||
|
||||
## Key Features
|
||||
- **Authentication**: Login flow with Apple authentication support.
|
||||
- **Training Plans**: Creation and management of personalized pilates training plans.
|
||||
- **Workouts**: Daily workout tracking and session management.
|
||||
- **AI Features**: AI coach chat and posture assessment capabilities.
|
||||
- **Health Integration**: Integration with health data tracking.
|
||||
- **Content Management**: Article reading and educational content.
|
||||
- **Challenge System**: Challenge participation and progress tracking.
|
||||
- **User Profiles**: Personal information management and goal setting.
|
||||
### Core Stack
|
||||
- **Framework**: React Native (Expo Prebuild/Ejected) with TypeScript
|
||||
- **Navigation**: Expo Router with file-based routing in `app/` directory
|
||||
- **State Management**: Redux Toolkit with domain-specific slices in `store/`
|
||||
- **Styling**: Custom theme system with light/dark mode support
|
||||
|
||||
## Directory Structure
|
||||
- `app/`: Main application screens and routing
|
||||
- `components/`: Reusable UI components
|
||||
- `constants/`: Application constants and configuration
|
||||
- `hooks/`: Custom React hooks
|
||||
- `services/`: API service layer
|
||||
- `store/`: Redux store and slices
|
||||
- `types/`: TypeScript type definitions
|
||||
### Directory Structure
|
||||
- **`app/`** - Expo Router screens; tab flows in `app/(tabs)/`, feature-specific pages in nested directories
|
||||
- **`store/`** - Redux slices organized by feature (user, nutrition, workout, etc.)
|
||||
- **`services/`** - API services, backend integration, and data layer logic
|
||||
- **`components/`** - Reusable UI components and domain-specific components
|
||||
- **`hooks/`** - Custom React hooks including typed Redux hooks (`hooks/redux.ts`)
|
||||
- **`utils/`** - Utility functions (health data, notifications, fasting, etc.)
|
||||
- **`contexts/`** - React Context providers (ToastContext, MembershipModalContext)
|
||||
- **`constants/`** - Route definitions (`Routes.ts`), colors, and app-wide constants
|
||||
- **`types/`** - TypeScript type definitions
|
||||
- **`assets/`** - Images, fonts, and media files
|
||||
- **`ios/`** - iOS native code and configuration
|
||||
|
||||
### Navigation
|
||||
- **File-based routing**: Pages defined by file structure in `app/`
|
||||
- **Tab navigation**: Main tabs in `app/(tabs)/` (Explore, Coach, Statistics, Challenges, Personal, Fasting)
|
||||
- **Route constants**: All route paths defined in `constants/Routes.ts`
|
||||
- **Nested layouts**: Feature-specific layouts in nested directories (e.g., `app/nutrition/_layout.tsx`)
|
||||
|
||||
### State Management
|
||||
- **Redux slices**: Feature-based state organization (17+ slices including user, nutrition, workout, mood, etc.)
|
||||
- **Auto-sync middleware**: Listener middleware automatically syncs checkin data changes to backend
|
||||
- **Typed hooks**: Use `useAppSelector` and `useAppDispatch` from `hooks/redux.ts` for type safety
|
||||
- **Persistence**: AsyncStorage for local data persistence
|
||||
|
||||
### UI System
|
||||
- **Themed components**: `ThemedText`, `ThemedView` with dynamic color scheme support
|
||||
- **Custom icons**: `IconSymbol` component for iOS SF Symbols
|
||||
- **UI library**: Reusable components in `components/ui/`
|
||||
- **Colors**: Centralized in `constants/Colors.ts`
|
||||
- **Safe areas**: `useSafeAreaTop` and `useSafeAreaWithPadding` hooks for device-safe layouts
|
||||
|
||||
### Data Layer
|
||||
- **API client**: Centralized in `services/api.ts` with interceptors and error handling
|
||||
- **Service modules**: Domain-specific services in `services/` (nutrition, workout, notifications, etc.)
|
||||
- **Background tasks**: Managed by `backgroundTaskManager.ts` for sync operations
|
||||
- **Local storage**: AsyncStorage for offline-first data persistence
|
||||
|
||||
### Native Integration
|
||||
- **HealthKit**: Health data integration in `utils/health.ts` and `utils/healthKit.ts`
|
||||
- **Apple Authentication**: Configured in Expo settings
|
||||
- **Camera & Photos**: Food recognition and posture assessment features
|
||||
- **Push Notifications**: `services/notifications.ts` with background task support
|
||||
- **Haptic Feedback**: `utils/haptics.ts` for user interactions
|
||||
- **Quick Actions**: Expo quick actions integration
|
||||
|
||||
### Context Providers
|
||||
- **ToastContext** - Global toast notification system
|
||||
- **MembershipModalContext** - VIP membership feature access control
|
||||
|
||||
## Key Architecture Patterns
|
||||
|
||||
- **Redux Auto-sync**: Listener middleware in `store/index.ts` automatically syncs checkin data changes to backend
|
||||
- **Type-safe Navigation**: Expo Router with TypeScript for compile-time route safety
|
||||
- **Authentication Flow**: `pushIfAuthedElseLogin` function handles auth-protected navigation
|
||||
- **Theme System**: Dynamic theming with light/dark mode and color tokens
|
||||
- **Service Layer**: Centralized API client with interceptors and error handling
|
||||
- **Background Sync**: Automatic data synchronization via background task manager
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Import Patterns
|
||||
- **Absolute imports**: Use `@/` prefix for all internal imports (e.g., `@/store`, `@/services/api`)
|
||||
- **Alias configuration**: Defined in `tsconfig.json` paths
|
||||
|
||||
### Redux Patterns
|
||||
- **Feature slices**: Each feature has its own slice (userSlice.ts, nutritionSlice.ts, etc.)
|
||||
- **Typed hooks**: Always use `useAppSelector` and `useAppDispatch` from `hooks/redux.ts`
|
||||
- **Async actions**: Use Redux Toolkit thunks for async operations
|
||||
- **Auto-sync**: Listener middleware handles automatic data synchronization
|
||||
|
||||
### Naming Conventions
|
||||
- **Components**: PascalCase (e.g., `ThemedText.tsx`, `FitnessRingsCard.tsx`)
|
||||
- **Hooks**: camelCase with "use" prefix (e.g., `useAppSelector`, `useSafeAreaTop`)
|
||||
- **Utilities**: camelCase (e.g., `health.ts`, `notificationHelpers.ts`)
|
||||
- **Screen files**: kebab-case (e.g., `ai-posture-assessment.tsx`, `nutrition-label-analysis.tsx`)
|
||||
- **Constants**: UPPER_SNAKE_CASE for values, PascalCase for types
|
||||
|
||||
### Code Style
|
||||
- **ESLint**: Configured with `eslint-config-expo` in `eslint.config.js`
|
||||
- **Formatting**: 2 spaces, trailing commas, single quotes (Prettier defaults)
|
||||
- **TypeScript**: Strict mode enabled, use proper type annotations
|
||||
|
||||
### Navigation & Routing
|
||||
- **Route constants**: Always use constants from `constants/Routes.ts` for navigation
|
||||
- **Auth guards**: Implement using `useAuthGuard` hook for protected features
|
||||
- **Typed routes**: Leverage Expo Router's TypeScript integration
|
||||
|
||||
### Testing Guidelines
|
||||
- **Minimal automated tests**: Add Jest + React Native Testing Library for complex logic
|
||||
- **HealthKit testing**: Requires real device; verify on iOS Simulator when possible
|
||||
- **Integration tests**: Include reproduction steps and logs in PR descriptions
|
||||
|
||||
### iOS Development
|
||||
- **Native changes**: Update `ios/` directory and re-run `npm run ios` after modifying Swift or entitlements
|
||||
- **HealthKit**: Requires entitlements configuration; coordinate with release engineering
|
||||
- **App signing**: Keep bundle IDs consistent with `app.json` and iOS project configuration
|
||||
- **App Groups**: Required for widget and quick actions integration
|
||||
|
||||
### Git Workflow
|
||||
- **Conventional Commits**: Use `feat`, `fix`, `chore` prefixes with optional scope
|
||||
- **PR descriptions**: Include problem, solution, test evidence (screenshots, commands), iOS setup notes
|
||||
- **Change grouping**: Group related changes; avoid bundling unrelated features
|
||||
- 总是先总结方案,等我确认之后,再进行实现
|
||||
@@ -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)
|
||||
@@ -92,8 +92,10 @@ android {
|
||||
applicationId 'com.anonymous.digitalpilates'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2
|
||||
versionName "1.0.2"
|
||||
versionCode 1
|
||||
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,13 +1,14 @@
|
||||
<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>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -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
|
||||
@@ -41,6 +41,11 @@ newArchEnabled=true
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=false
|
||||
|
||||
# Use this property to enable edge-to-edge display support.
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=true
|
||||
|
||||
# Enable GIF support in React Native images (~200 B increase)
|
||||
expo.gif.enabled=true
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
@@ -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,58 +1,77 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "普拉提助手",
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.3",
|
||||
"version": "1.1.4",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/logo.jpeg",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
"icon": "./assets/logo.png",
|
||||
"newArchEnabled": true,
|
||||
"jsEngine": "jsc",
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"deploymentTarget": "16.0",
|
||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSCameraUsageDescription": "应用需要使用相机以拍摄您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryUsageDescription": "应用需要访问相册以选择您的体态照片用于AI测评。",
|
||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。"
|
||||
}
|
||||
"NSPhotoLibraryAddUsageDescription": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
||||
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
|
||||
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
|
||||
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
||||
"BGTaskSchedulerPermittedIdentifiers": [
|
||||
"com.expo.modules.backgroundtask.processing",
|
||||
"com.anonymous.digitalpilates.task",
|
||||
"com.anonymous.digitalpilates.processing"
|
||||
],
|
||||
"UIBackgroundModes": [
|
||||
"processing",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/logo.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/logo.jpeg"
|
||||
"appleTeamId": "756WVXJ6MT"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/logo.jpeg",
|
||||
"imageWidth": 200,
|
||||
"image": "./assets/logo.png",
|
||||
"imageWidth": 40,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-health",
|
||||
"expo-notifications",
|
||||
{
|
||||
"enableHealthAPI": true,
|
||||
"healthSharePermission": "应用需要访问您的健康数据(步数与能量消耗)以展示运动统计。"
|
||||
"icon": "./assets/logo.png",
|
||||
"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,119 +1,173 @@
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
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 { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { selectEnabledTabs } from '@/store/tabBarConfigSlice';
|
||||
|
||||
// Tab configuration
|
||||
type TabConfig = {
|
||||
icon: string;
|
||||
titleKey: string;
|
||||
};
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
|
||||
};
|
||||
|
||||
export default function TabLayout() {
|
||||
const { t } = useTranslation();
|
||||
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 === 'coach' && pathname === '/coach') ||
|
||||
(routeName === 'explore' && pathname === '/explore') ||
|
||||
pathname.includes(routeName);
|
||||
// 获取已启用的标签配置(按自定义顺序)
|
||||
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||
|
||||
return {
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
||||
tabBarButton: (props) => {
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
statistics: ROUTES.TAB_STATISTICS,
|
||||
medications: ROUTES.TAB_MEDICATIONS,
|
||||
fasting: ROUTES.TAB_FASTING,
|
||||
challenges: ROUTES.TAB_CHALLENGES,
|
||||
personal: ROUTES.TAB_PERSONAL,
|
||||
};
|
||||
|
||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||
};
|
||||
|
||||
// Custom tab button component
|
||||
const createTabButton = (routeName: string) => (props: any) => {
|
||||
const { onPress } = props;
|
||||
const tabConfig = TAB_CONFIGS[routeName];
|
||||
|
||||
if (!tabConfig) return null;
|
||||
|
||||
const isSelected = isTabSelected(routeName);
|
||||
|
||||
const handlePress = (event: any) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
onPress && onPress(event);
|
||||
onPress?.(event);
|
||||
};
|
||||
|
||||
// 基于 routeName 设置图标与标题,避免 tabBarIcon 的包装导致文字裁剪
|
||||
const getIconAndTitle = () => {
|
||||
switch (routeName) {
|
||||
case 'index':
|
||||
return { icon: 'house.fill', title: '首页' } as const;
|
||||
case 'coach':
|
||||
return { icon: 'person.3.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,
|
||||
marginLeft: 6
|
||||
}}
|
||||
// 选中态下不限制行数,避免大屏布局下被裁剪成省略号
|
||||
numberOfLines={0 as any}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
{t(tabConfig.titleKey)}
|
||||
</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,
|
||||
@@ -123,121 +177,47 @@ 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="coach"
|
||||
options={{
|
||||
title: '教练',
|
||||
tabBarIcon: ({ color }) => {
|
||||
const isCoachSelected = pathname === '/coach';
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<IconSymbol size={22} name="person.3.fill" color={color} />
|
||||
{isCoachSelected && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
教练
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: '探索',
|
||||
tabBarIcon: ({ color }) => {
|
||||
const isExploreSelected = pathname === '/explore';
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<IconSymbol size={22} name="paperplane.fill" color={color} />
|
||||
{isExploreSelected && (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
探索
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
});
|
||||
|
||||
<Tabs.Screen
|
||||
name="personal"
|
||||
options={{
|
||||
title: '个人',
|
||||
tabBarIcon: ({ color }) => {
|
||||
const isPersonalSelected = pathname === '/personal';
|
||||
// 根据配置渲染标签页
|
||||
if (glassEffectAvailable) {
|
||||
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>
|
||||
<NativeTabs>
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
return (
|
||||
<NativeTabs.Trigger key={tab.id} name={tab.id}>
|
||||
<Icon sf={tabConfig.icon as any} drawable="custom_android_drawable" />
|
||||
<Label>{t(tabConfig.titleKey)}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
);
|
||||
},
|
||||
}}
|
||||
})}
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
|
||||
// 确定初始路由(第一个启用的标签)
|
||||
const initialRouteName = enabledTabs.length > 0 ? enabledTabs[0].id : 'statistics';
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName={initialRouteName}
|
||||
screenOptions={({ route }) => getScreenOptions(route.name)}
|
||||
>
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
return (
|
||||
<Tabs.Screen
|
||||
key={tab.id}
|
||||
name={tab.id}
|
||||
options={{ title: t(tabConfig.titleKey) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
845
app/(tabs)/challenges.tsx
Normal file
@@ -0,0 +1,845 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
fetchChallenges,
|
||||
joinChallengeByCode,
|
||||
resetJoinByCodeState,
|
||||
selectChallengeCards,
|
||||
selectChallengesListError,
|
||||
selectChallengesListStatus,
|
||||
selectCustomChallengeCards,
|
||||
selectJoinByCodeError,
|
||||
selectJoinByCodeStatus,
|
||||
selectOfficialChallengeCards,
|
||||
type ChallengeCardViewModel,
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
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 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 { t } = useI18n();
|
||||
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
const colorTokens = Colors[theme];
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
const allChallenges = useAppSelector(selectChallengeCards);
|
||||
const customChallenges = useAppSelector(selectCustomChallengeCards);
|
||||
|
||||
|
||||
const officialChallenges = useAppSelector(selectOfficialChallengeCards);
|
||||
const joinedCustomChallenges = useMemo(
|
||||
() => customChallenges.filter((item) => item.isJoined),
|
||||
[customChallenges]
|
||||
);
|
||||
const listStatus = useAppSelector(selectChallengesListStatus);
|
||||
const listError = useAppSelector(selectChallengesListError);
|
||||
const joinByCodeStatus = useAppSelector(selectJoinByCodeStatus);
|
||||
const joinByCodeError = useAppSelector(selectJoinByCodeError);
|
||||
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
||||
const [shareCodeInput, setShareCodeInput] = useState('');
|
||||
const ongoingChallenges = useMemo(() => {
|
||||
const now = dayjs();
|
||||
return allChallenges.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;
|
||||
});
|
||||
}, [allChallenges]);
|
||||
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];
|
||||
|
||||
useEffect(() => {
|
||||
if (!joinModalVisible) {
|
||||
dispatch(resetJoinByCodeState());
|
||||
setShareCodeInput('');
|
||||
}
|
||||
}, [dispatch, joinModalVisible]);
|
||||
|
||||
const handleCreatePress = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/challenges/create-custom');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
const handleOpenJoin = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setJoinModalVisible(true);
|
||||
}, [ensureLoggedIn]);
|
||||
|
||||
const isJoiningByCode = joinByCodeStatus === 'loading';
|
||||
|
||||
const handleSubmitShareCode = useCallback(async () => {
|
||||
if (isJoiningByCode) return;
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
if (!shareCodeInput.trim()) {
|
||||
Toast.warning(t('challenges.invalidInviteCode'));
|
||||
return;
|
||||
}
|
||||
const formatted = shareCodeInput.trim().toUpperCase();
|
||||
try {
|
||||
const result = await dispatch(joinChallengeByCode(formatted)).unwrap();
|
||||
await dispatch(fetchChallenges());
|
||||
setJoinModalVisible(false);
|
||||
Toast.success(t('challenges.joinSuccess'));
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } });
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : t('challenges.joinFailed');
|
||||
Toast.error(message);
|
||||
}
|
||||
}, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]);
|
||||
|
||||
const renderChallenges = () => {
|
||||
if (listStatus === 'loading' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (listStatus === 'failed' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||
{listError ?? t('challenges.loadFailed')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => dispatch(fetchChallenges())}
|
||||
>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (customChallenges.length === 0 && officialChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.cardGroups}>
|
||||
{joinedCustomChallenges.length ? (
|
||||
<>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
|
||||
</View>
|
||||
<View style={styles.cardsContainer}>
|
||||
{joinedCustomChallenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
|
||||
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
|
||||
</View>
|
||||
{officialChallenges.length ? (
|
||||
<View style={styles.cardsContainer}>
|
||||
{officialChallenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.stateContainer, styles.customEmpty]}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
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 }]}>{t('challenges.title')}</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.joinButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255,255,255,0.18)"
|
||||
isInteractive
|
||||
>
|
||||
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.createButton}
|
||||
tintColor="rgba(255,255,255,0.22)"
|
||||
isInteractive
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#0f1528" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.createButton, styles.createButtonFallback]}>
|
||||
<Ionicons name="add" size={18} color={colorTokens.text} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</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>
|
||||
<ConfirmationSheet
|
||||
visible={joinModalVisible}
|
||||
onClose={() => setJoinModalVisible(false)}
|
||||
onConfirm={handleSubmitShareCode}
|
||||
title={t('challenges.joinModal.title')}
|
||||
description={t('challenges.joinModal.description')}
|
||||
confirmText={isJoiningByCode ? t('challenges.joinModal.joining') : t('challenges.joinModal.confirm')}
|
||||
cancelText={t('challenges.joinModal.cancel')}
|
||||
loading={isJoiningByCode}
|
||||
content={
|
||||
<View style={styles.modalInputWrapper}>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder={t('challenges.joinModal.placeholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={shareCodeInput}
|
||||
onChangeText={(text) => setShareCodeInput(text.toUpperCase())}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
keyboardType="default"
|
||||
maxLength={12}
|
||||
/>
|
||||
{joinByCodeError && joinModalVisible ? (
|
||||
<Text style={styles.modalError}>{joinByCodeError}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ChallengeCardProps = {
|
||||
challenge: ChallengeCardViewModel;
|
||||
surfaceColor: string;
|
||||
textColor: string;
|
||||
mutedColor: string;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||
const { t } = useI18n();
|
||||
const statusLabel = t(`challenges.statusLabels.${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 ? ` · ${t('challenges.joined')}` : ''}
|
||||
</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 as string}
|
||||
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: 24,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
joinButtonGlass: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
createButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
},
|
||||
createButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
cardsContainer: {
|
||||
gap: 18,
|
||||
},
|
||||
cardGroups: {
|
||||
gap: 20,
|
||||
},
|
||||
sectionHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionHeaderText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
customEmpty: {
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
primaryGhostButton: {
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: 14,
|
||||
},
|
||||
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,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
cardDate: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
cardParticipants: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
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,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
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,
|
||||
},
|
||||
modalInputWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 6,
|
||||
},
|
||||
modalInput: {
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.5,
|
||||
color: '#0f1528',
|
||||
},
|
||||
modalError: {
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
});
|
||||
1158
app/(tabs)/coach.tsx
@@ -1,493 +0,0 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { BMICard } from '@/components/BMICard';
|
||||
import { CircularRing } from '@/components/CircularRing';
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
// 使用 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);
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
const [trainingProgress, setTrainingProgress] = useState(0.8); // 暂定静态80%
|
||||
|
||||
// 记录最近一次请求的“日期键”,避免旧请求覆盖新结果
|
||||
const latestRequestKeyRef = useRef<string | null>(null);
|
||||
|
||||
const getDateKey = (d: Date) => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||
|
||||
const loadHealthData = async (targetDate?: Date) => {
|
||||
try {
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
setIsLoading(true);
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
if (!ok) {
|
||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||||
console.warn(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// 若未显式传入日期,按当前选中索引推导日期
|
||||
const derivedDate = targetDate ?? days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||
const data = await fetchHealthDataForDate(derivedDate);
|
||||
|
||||
console.log('设置UI状态:', data);
|
||||
// 仅当该请求仍是最新时,才应用结果
|
||||
if (latestRequestKeyRef.current === requestKey) {
|
||||
setStepCount(data.steps);
|
||||
setActiveCalories(Math.round(data.activeEnergyBurned));
|
||||
setAnimToken((t) => t + 1);
|
||||
} else {
|
||||
console.log('忽略过期健康数据请求结果,key=', requestKey, '最新key=', latestRequestKeyRef.current);
|
||||
}
|
||||
console.log('=== HealthKit数据获取完成 ===');
|
||||
|
||||
} catch (error) {
|
||||
console.error('HealthKit流程出现异常:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
// 聚焦时按当前选中的日期加载,避免与用户手动选择的日期不一致
|
||||
loadHealthData();
|
||||
}, [selectedIndex])
|
||||
);
|
||||
|
||||
// 日期点击时,加载对应日期数据
|
||||
const onSelectDate = (index: number) => {
|
||||
setSelectedIndex(index);
|
||||
scrollToIndex(index);
|
||||
const target = days[index]?.date?.toDate();
|
||||
if (target) {
|
||||
loadHealthData(target);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: theme === 'light' ? colorTokens.pageBackgroundEmphasis : colorTokens.background }]}>
|
||||
<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={() => onSelectDate(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>
|
||||
|
||||
{/* 指标行:左大卡(训练时间),右两小卡(消耗卡路里、步数) */}
|
||||
<View style={styles.metricsRow}>
|
||||
<View style={[styles.trainingCard, styles.metricsLeft]}>
|
||||
<Text style={styles.cardTitleSecondary}>训练时间</Text>
|
||||
<View style={styles.trainingContent}>
|
||||
<CircularRing
|
||||
size={120}
|
||||
strokeWidth={12}
|
||||
trackColor="#E2D9FD"
|
||||
progressColor="#8B74F3"
|
||||
progress={trainingProgress}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.metricsRight}>
|
||||
<View style={[styles.metricsRightCard, styles.caloriesCard, { minHeight: 88 }]}>
|
||||
<Text style={styles.cardTitleSecondary}>消耗卡路里</Text>
|
||||
{activeCalories != null ? (
|
||||
<AnimatedNumber
|
||||
value={activeCalories}
|
||||
resetToken={animToken}
|
||||
style={styles.caloriesValue}
|
||||
format={(v) => `${Math.round(v)} 千卡`}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.caloriesValue}>——</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>
|
||||
{stepCount != null ? (
|
||||
<AnimatedNumber
|
||||
value={stepCount}
|
||||
resetToken={animToken}
|
||||
style={styles.stepsValue}
|
||||
format={(v) => `${Math.round(v)}/${stepGoal}`}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.stepsValue}>——/{stepGoal}</Text>
|
||||
)}
|
||||
<ProgressBar
|
||||
progress={Math.min(1, Math.max(0, (stepCount ?? 0) / stepGoal))}
|
||||
height={18}
|
||||
trackColor="#FFEBCB"
|
||||
fillColor="#FFC365"
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* BMI 指数卡片 */}
|
||||
<BMICard
|
||||
weight={userProfile?.weight ? parseFloat(userProfile.weight) : undefined}
|
||||
height={userProfile?.height ? parseFloat(userProfile.height) : undefined}
|
||||
/>
|
||||
</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,
|
||||
},
|
||||
});
|
||||
832
app/(tabs)/fasting.tsx
Normal file
@@ -0,0 +1,832 @@
|
||||
import { FastingOverviewCard } from '@/components/fasting/FastingOverviewCard';
|
||||
import { FastingPlanList } from '@/components/fasting/FastingPlanList';
|
||||
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
||||
import { NotificationErrorAlert } from '@/components/ui/NotificationErrorAlert';
|
||||
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useCountdown } from '@/hooks/useCountdown';
|
||||
import { useFastingCycleNotifications } from '@/hooks/useFastingCycleNotifications';
|
||||
import { useFastingNotifications } from '@/hooks/useFastingNotifications';
|
||||
import {
|
||||
clearActiveSchedule,
|
||||
completeCurrentCycleSession,
|
||||
hydrateFastingCycle,
|
||||
pauseFastingCycle,
|
||||
rescheduleActivePlan,
|
||||
resumeFastingCycle,
|
||||
scheduleFastingPlan,
|
||||
selectActiveCyclePlan,
|
||||
// 周期性断食相关的 selectors
|
||||
selectActiveFastingCycle,
|
||||
selectActiveFastingPlan,
|
||||
selectActiveFastingSchedule,
|
||||
selectCurrentCyclePlan,
|
||||
selectCurrentCycleSession,
|
||||
selectCurrentFastingPlan,
|
||||
selectCurrentFastingTimes,
|
||||
selectCycleHistory,
|
||||
selectIsInCycleMode,
|
||||
startFastingCycle,
|
||||
stopFastingCycle,
|
||||
updateFastingCycleTime
|
||||
} from '@/store/fastingSlice';
|
||||
import {
|
||||
buildDisplayWindow,
|
||||
getFastingPhase,
|
||||
getPhaseLabel,
|
||||
// 周期性断食相关的工具函数
|
||||
loadActiveFastingCycle,
|
||||
loadCurrentCycleSession,
|
||||
loadCycleHistory,
|
||||
loadPreferredPlanId,
|
||||
saveActiveFastingCycle,
|
||||
saveCurrentCycleSession,
|
||||
saveCycleHistory,
|
||||
savePreferredPlanId
|
||||
} from '@/utils/fasting';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FastingTabScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const insets = useSafeAreaInsets();
|
||||
const scrollViewRef = React.useRef<ScrollView>(null);
|
||||
|
||||
// 单次断食计划的状态
|
||||
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||
const activePlan = useAppSelector(selectActiveFastingPlan);
|
||||
|
||||
// 周期性断食计划的状态
|
||||
const activeCycle = useAppSelector(selectActiveFastingCycle);
|
||||
const currentCycleSession = useAppSelector(selectCurrentCycleSession);
|
||||
const cycleHistory = useAppSelector(selectCycleHistory);
|
||||
const activeCyclePlan = useAppSelector(selectActiveCyclePlan);
|
||||
const currentCyclePlan = useAppSelector(selectCurrentCyclePlan);
|
||||
|
||||
// 统一的当前断食信息(优先显示周期性)
|
||||
const currentPlan = useAppSelector(selectCurrentFastingPlan);
|
||||
const currentTimes = useAppSelector(selectCurrentFastingTimes);
|
||||
const isInCycleMode = useAppSelector(selectIsInCycleMode);
|
||||
|
||||
const defaultPlan = FASTING_PLANS.find((plan) => plan.id === '14-10') ?? FASTING_PLANS[0];
|
||||
const [preferredPlanId, setPreferredPlanId] = useState<string | undefined>(currentPlan?.id ?? undefined);
|
||||
|
||||
// 数据持久化
|
||||
useEffect(() => {
|
||||
if (!currentPlan?.id) return;
|
||||
setPreferredPlanId(currentPlan.id);
|
||||
void savePreferredPlanId(currentPlan.id);
|
||||
}, [currentPlan?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const hydratePreferredPlan = async () => {
|
||||
try {
|
||||
const savedPlanId = await loadPreferredPlanId();
|
||||
if (cancelled) return;
|
||||
if (currentPlan?.id) return;
|
||||
if (savedPlanId && getPlanById(savedPlanId)) {
|
||||
setPreferredPlanId(savedPlanId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('读取断食首选计划失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
hydratePreferredPlan();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentPlan?.id]);
|
||||
|
||||
// 加载周期性断食数据
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const hydrateCycleData = async () => {
|
||||
try {
|
||||
if (cancelled) return;
|
||||
|
||||
const [cycleData, sessionData, historyData] = await Promise.all([
|
||||
loadActiveFastingCycle(),
|
||||
loadCurrentCycleSession(),
|
||||
loadCycleHistory(),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
dispatch(hydrateFastingCycle({
|
||||
activeCycle: cycleData,
|
||||
currentCycleSession: sessionData,
|
||||
cycleHistory: historyData,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('加载周期性断食数据失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
hydrateCycleData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// 保存周期性断食数据,增加错误处理
|
||||
useEffect(() => {
|
||||
const saveCycleData = async () => {
|
||||
try {
|
||||
if (activeCycle) {
|
||||
await saveActiveFastingCycle(activeCycle);
|
||||
} else {
|
||||
await saveActiveFastingCycle(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存周期性断食计划失败', error);
|
||||
// TODO: 可以在这里添加用户提示
|
||||
}
|
||||
};
|
||||
saveCycleData();
|
||||
}, [activeCycle]);
|
||||
|
||||
useEffect(() => {
|
||||
const saveSessionData = async () => {
|
||||
try {
|
||||
if (currentCycleSession) {
|
||||
await saveCurrentCycleSession(currentCycleSession);
|
||||
} else {
|
||||
await saveCurrentCycleSession(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存断食会话失败', error);
|
||||
// TODO: 可以在这里添加用户提示
|
||||
}
|
||||
};
|
||||
saveSessionData();
|
||||
}, [currentCycleSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const saveHistoryData = async () => {
|
||||
try {
|
||||
await saveCycleHistory(cycleHistory);
|
||||
} catch (error) {
|
||||
console.error('保存断食历史失败', error);
|
||||
// TODO: 可以在这里添加用户提示
|
||||
}
|
||||
};
|
||||
saveHistoryData();
|
||||
}, [cycleHistory]);
|
||||
|
||||
// 使用单次断食通知管理 hook
|
||||
const {
|
||||
isReady: notificationsReady,
|
||||
isLoading: notificationsLoading,
|
||||
error: notificationError,
|
||||
notificationIds,
|
||||
lastSyncTime,
|
||||
verifyAndSync,
|
||||
forceSync,
|
||||
clearError,
|
||||
} = useFastingNotifications(activeSchedule, activePlan);
|
||||
|
||||
// 使用周期性断食通知管理 hook
|
||||
const {
|
||||
isReady: cycleNotificationsReady,
|
||||
isLoading: cycleNotificationsLoading,
|
||||
error: cycleNotificationError,
|
||||
lastSyncTime: cycleLastSyncTime,
|
||||
verifyAndSync: verifyAndSyncCycle,
|
||||
forceSync: forceSyncCycle,
|
||||
clearError: clearCycleError,
|
||||
} = useFastingCycleNotifications(activeCycle, currentCycleSession, currentCyclePlan);
|
||||
|
||||
// 每次进入页面时验证通知
|
||||
// 添加节流机制,避免频繁触发验证
|
||||
const lastVerifyTimeRef = React.useRef<number>(0);
|
||||
const lastCycleVerifyTimeRef = React.useRef<number>(0);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastVerify = now - lastVerifyTimeRef.current;
|
||||
|
||||
// 如果距离上次验证不足 30 秒,跳过本次验证
|
||||
if (timeSinceLastVerify < 30000) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVerifyTimeRef.current = now;
|
||||
verifyAndSync();
|
||||
}, [verifyAndSync])
|
||||
);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastVerify = now - lastCycleVerifyTimeRef.current;
|
||||
|
||||
// 如果距离上次验证不足 30 秒,跳过本次验证
|
||||
if (timeSinceLastVerify < 30000) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastCycleVerifyTimeRef.current = now;
|
||||
verifyAndSyncCycle();
|
||||
}, [verifyAndSyncCycle])
|
||||
);
|
||||
|
||||
// 使用统一的当前断食时间
|
||||
const scheduleStart = useMemo(() => {
|
||||
if (currentTimes) {
|
||||
return new Date(currentTimes.startISO);
|
||||
}
|
||||
return undefined;
|
||||
}, [currentTimes]);
|
||||
|
||||
const scheduleEnd = useMemo(() => {
|
||||
if (currentTimes) {
|
||||
return new Date(currentTimes.endISO);
|
||||
}
|
||||
return undefined;
|
||||
}, [currentTimes]);
|
||||
|
||||
const phase = getFastingPhase(scheduleStart ?? null, scheduleEnd ?? null);
|
||||
const countdownTarget = phase === 'fasting' ? scheduleEnd : scheduleStart;
|
||||
const { formatted: countdownValue } = useCountdown({ target: countdownTarget ?? null });
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!scheduleStart || !scheduleEnd) return 0;
|
||||
const total = scheduleEnd.getTime() - scheduleStart.getTime();
|
||||
if (total <= 0) return 0;
|
||||
const now = Date.now();
|
||||
if (now <= scheduleStart.getTime()) return 0;
|
||||
if (now >= scheduleEnd.getTime()) return 1;
|
||||
return (now - scheduleStart.getTime()) / total;
|
||||
}, [scheduleStart, scheduleEnd]);
|
||||
|
||||
const displayWindow = buildDisplayWindow(scheduleStart ?? null, scheduleEnd ?? null);
|
||||
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
// 显示通知错误(如果有)
|
||||
useEffect(() => {
|
||||
if (notificationError) {
|
||||
console.warn('断食通知错误:', notificationError);
|
||||
// 可以在这里添加用户提示,比如 Toast 或 Snackbar
|
||||
}
|
||||
}, [notificationError]);
|
||||
|
||||
const recommendedDate = useMemo(() => {
|
||||
const planToUse = currentPlan || defaultPlan;
|
||||
return getRecommendedStart(planToUse);
|
||||
}, [currentPlan, defaultPlan]);
|
||||
|
||||
// 调试信息(开发环境)
|
||||
useEffect(() => {
|
||||
if (__DEV__ && lastSyncTime) {
|
||||
console.log('单次断食通知状态:', {
|
||||
ready: notificationsReady,
|
||||
loading: notificationsLoading,
|
||||
error: notificationError,
|
||||
notificationIds,
|
||||
lastSyncTime,
|
||||
schedule: activeSchedule?.startISO,
|
||||
plan: activePlan?.id,
|
||||
});
|
||||
}
|
||||
}, [notificationsReady, notificationsLoading, notificationError, notificationIds, lastSyncTime, activeSchedule?.startISO, activePlan?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (__DEV__ && cycleLastSyncTime) {
|
||||
console.log('周期性断食通知状态:', {
|
||||
ready: cycleNotificationsReady,
|
||||
loading: cycleNotificationsLoading,
|
||||
error: cycleNotificationError,
|
||||
lastSyncTime: cycleLastSyncTime,
|
||||
cycle: activeCycle?.planId,
|
||||
session: currentCycleSession?.cycleDate,
|
||||
});
|
||||
}
|
||||
}, [cycleNotificationsReady, cycleNotificationsLoading, cycleNotificationError, cycleLastSyncTime, activeCycle?.planId, currentCycleSession?.cycleDate]);
|
||||
|
||||
// 周期性断食的自动续订逻辑
|
||||
// 移除1小时限制,但需要用户手动确认开始下一轮周期
|
||||
useEffect(() => {
|
||||
if (!currentCycleSession || !activeCycle || !currentCyclePlan) return;
|
||||
if (!activeCycle.enabled) return; // 如果周期已暂停,不自动完成
|
||||
if (phase !== 'completed') return;
|
||||
|
||||
const end = dayjs(currentCycleSession.endISO);
|
||||
if (!end.isValid()) return;
|
||||
|
||||
const now = dayjs();
|
||||
if (now.isBefore(end)) return;
|
||||
|
||||
// 检查当前会话是否已经标记为完成
|
||||
if (currentCycleSession.completed) {
|
||||
if (__DEV__) {
|
||||
console.log('当前会话已完成,跳过自动完成');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('自动完成当前断食周期:', {
|
||||
cycleDate: currentCycleSession.cycleDate,
|
||||
planId: currentCycleSession.planId,
|
||||
endTime: end.format('YYYY-MM-DD HH:mm'),
|
||||
timeSinceEnd: now.diff(end, 'minute') + '分钟',
|
||||
});
|
||||
}
|
||||
|
||||
// 完成当前周期并创建下一个周期
|
||||
// 这会自动创建下一天的会话,不需要用户手动操作
|
||||
dispatch(completeCurrentCycleSession());
|
||||
}, [dispatch, currentCycleSession, activeCycle, currentCyclePlan, phase]);
|
||||
|
||||
// 保留原有的单次断食自动续订逻辑(向后兼容)
|
||||
useEffect(() => {
|
||||
if (!activeSchedule || !activePlan) return;
|
||||
if (phase !== 'completed') return;
|
||||
|
||||
const start = dayjs(activeSchedule.startISO);
|
||||
const end = dayjs(activeSchedule.endISO);
|
||||
if (!start.isValid() || !end.isValid()) return;
|
||||
|
||||
const now = dayjs();
|
||||
if (now.isBefore(end)) return;
|
||||
|
||||
// 检查是否在短时间内已经续订过,避免重复续订
|
||||
const timeSinceEnd = now.diff(end, 'minute');
|
||||
if (timeSinceEnd > 60) {
|
||||
// 如果周期结束超过1小时,说明用户可能不再需要自动续订
|
||||
if (__DEV__) {
|
||||
console.log('断食周期结束超过1小时,不自动续订');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用每日固定时间计算下一个周期
|
||||
// 保持原始的开始时间(小时和分钟),只增加日期
|
||||
const originalStartHour = start.hour();
|
||||
const originalStartMinute = start.minute();
|
||||
|
||||
// 计算下一个开始时间:明天的同一时刻
|
||||
let nextStart = now.startOf('day').hour(originalStartHour).minute(originalStartMinute);
|
||||
|
||||
// 如果计算出的时间在当前时间之前,则使用后天的同一时刻
|
||||
if (nextStart.isBefore(now)) {
|
||||
nextStart = nextStart.add(1, 'day');
|
||||
}
|
||||
|
||||
const nextEnd = nextStart.add(activePlan.fastingHours, 'hour');
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('自动续订断食周期:', {
|
||||
oldStart: start.format('YYYY-MM-DD HH:mm'),
|
||||
oldEnd: end.format('YYYY-MM-DD HH:mm'),
|
||||
nextStart: nextStart.format('YYYY-MM-DD HH:mm'),
|
||||
nextEnd: nextEnd.format('YYYY-MM-DD HH:mm'),
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(rescheduleActivePlan({
|
||||
start: nextStart.toISOString(),
|
||||
origin: 'auto',
|
||||
}));
|
||||
}, [dispatch, activeSchedule, activePlan, phase]);
|
||||
|
||||
const handleAdjustStart = () => {
|
||||
setShowPicker(true);
|
||||
};
|
||||
|
||||
const handleConfirmStart = (date: Date) => {
|
||||
// 如果没有当前计划,使用默认计划
|
||||
const planToUse = currentPlan || defaultPlan;
|
||||
|
||||
// 如果处于周期性模式,更新周期性时间
|
||||
if (isInCycleMode && activeCycle) {
|
||||
const hour = date.getHours();
|
||||
const minute = date.getMinutes();
|
||||
dispatch(updateFastingCycleTime({ startHour: hour, startMinute: minute }));
|
||||
} else if (activeSchedule) {
|
||||
// 单次断食模式,重新安排
|
||||
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||||
} else {
|
||||
// 创建新的单次断食计划
|
||||
dispatch(scheduleFastingPlan({ planId: planToUse.id, start: date.toISOString(), origin: 'manual' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPlan = (plan: FastingPlan) => {
|
||||
router.push(`${ROUTES.FASTING_PLAN_DETAIL}/${plan.id}`);
|
||||
};
|
||||
|
||||
const handleViewMeal = () => {
|
||||
router.push(ROUTES.FOOD_LIBRARY);
|
||||
};
|
||||
|
||||
const handleResetPlan = () => {
|
||||
// 如果没有活跃计划,不执行任何操作
|
||||
if (!currentPlan) return;
|
||||
|
||||
if (isInCycleMode) {
|
||||
// 停止周期性断食
|
||||
dispatch(stopFastingCycle());
|
||||
} else {
|
||||
// 清除单次断食
|
||||
dispatch(clearActiveSchedule());
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:启动周期性断食
|
||||
const handleStartCycle = async (plan: FastingPlan, startHour: number, startMinute: number) => {
|
||||
try {
|
||||
dispatch(startFastingCycle({
|
||||
planId: plan.id,
|
||||
startHour,
|
||||
startMinute
|
||||
}));
|
||||
|
||||
// 等待数据保存完成
|
||||
// 注意:dispatch 是同步的,但我们需要确保数据被正确保存
|
||||
console.log('周期性断食计划已启动', {
|
||||
planId: plan.id,
|
||||
startHour,
|
||||
startMinute
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('启动周期性断食失败', error);
|
||||
// TODO: 添加用户错误提示
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:暂停/恢复周期性断食
|
||||
const handleToggleCycle = async () => {
|
||||
if (!activeCycle) return;
|
||||
|
||||
try {
|
||||
if (activeCycle.enabled) {
|
||||
dispatch(pauseFastingCycle());
|
||||
console.log('周期性断食已暂停');
|
||||
} else {
|
||||
dispatch(resumeFastingCycle());
|
||||
console.log('周期性断食已恢复');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换周期性断食状态失败', error);
|
||||
// TODO: 添加用户错误提示
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.safeArea]}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={[styles.scrollContainer, {
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: 120
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.screenTitle}>轻断食</Text>
|
||||
<Text style={styles.screenSubtitle}>改善代谢 · 科学控脂 · 饮食不焦虑</Text>
|
||||
</View>
|
||||
|
||||
{/* 通知错误提示 */}
|
||||
<NotificationErrorAlert
|
||||
error={notificationError}
|
||||
onRetry={forceSync}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
|
||||
{currentPlan ? (
|
||||
<FastingOverviewCard
|
||||
plan={currentPlan}
|
||||
phaseLabel={getPhaseLabel(phase)}
|
||||
countdownLabel={phase === 'fasting' ? '距离进食还有' : '距离断食还有'}
|
||||
countdownValue={countdownValue}
|
||||
startDayLabel={displayWindow.startDayLabel}
|
||||
startTimeLabel={displayWindow.startTimeLabel}
|
||||
endDayLabel={displayWindow.endDayLabel}
|
||||
endTimeLabel={displayWindow.endTimeLabel}
|
||||
onAdjustStartPress={handleAdjustStart}
|
||||
onViewMealsPress={handleViewMeal}
|
||||
onResetPress={handleResetPlan}
|
||||
progress={progress}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyStateCard}>
|
||||
<View style={styles.emptyStateHeader}>
|
||||
<Text style={styles.emptyStateTitle}>开始您的断食之旅</Text>
|
||||
<Text style={styles.emptyStateSubtitle}>选择适合的断食计划,开启健康生活</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.emptyStateContent}>
|
||||
<View style={styles.emptyStateIcon}>
|
||||
<Ionicons name="time-outline" size={48} color="#6F7D87" />
|
||||
</View>
|
||||
<Text style={styles.emptyStateDescription}>
|
||||
断食可以帮助改善代谢、控制体重,让身体获得充分的休息和修复时间。
|
||||
</Text>
|
||||
<Text style={styles.defaultPlanInfo}>
|
||||
默认使用 14-10 热门计划(断食14小时,进食10小时)
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.emptyStateActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.primaryButton}
|
||||
onPress={() => setShowPicker(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>开始断食计划</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={() => {
|
||||
// 滚动到计划列表
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ y: 600, animated: true });
|
||||
}, 100);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>浏览断食方案</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentPlan && (
|
||||
<View style={styles.highlightCard}>
|
||||
<View style={styles.highlightHeader}>
|
||||
<Text style={styles.highlightTitle}>计划亮点</Text>
|
||||
<Text style={styles.highlightSubtitle}>{currentPlan.subtitle}</Text>
|
||||
</View>
|
||||
{currentPlan.highlights.map((highlight) => (
|
||||
<View key={highlight} style={styles.highlightItem}>
|
||||
<View style={[styles.highlightDot, { backgroundColor: currentPlan.theme.accent }]} />
|
||||
<Text style={styles.highlightText}>{highlight}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.resetRow}>
|
||||
<Text style={styles.resetHint}>
|
||||
如果计划与作息不符,可重新选择方案或调整开始时间。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<FastingPlanList
|
||||
plans={FASTING_PLANS}
|
||||
activePlanId={activePlan?.id ?? currentPlan?.id}
|
||||
onSelectPlan={handleSelectPlan}
|
||||
/>
|
||||
|
||||
{/* 参考文献入口 */}
|
||||
<View style={styles.referencesSection}>
|
||||
<TouchableOpacity
|
||||
style={styles.referencesButton}
|
||||
onPress={() => router.push(ROUTES.FASTING_REFERENCES)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.referencesGlass}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(46, 49, 66, 0.05)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<View style={styles.referencesContent}>
|
||||
<Ionicons name="library-outline" size={20} color="#2E3142" />
|
||||
<Text style={styles.referencesText}>参考文献与医学来源</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color="#6F7D87" />
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.referencesGlass, styles.referencesFallback]}>
|
||||
<View style={styles.referencesContent}>
|
||||
<Ionicons name="library-outline" size={20} color="#2E3142" />
|
||||
<Text style={styles.referencesText}>参考文献与医学来源</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color="#6F7D87" />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<FastingStartPickerModal
|
||||
visible={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
initialDate={scheduleStart}
|
||||
recommendedDate={recommendedDate}
|
||||
onConfirm={handleConfirmStart}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'white'
|
||||
},
|
||||
scrollContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
|
||||
},
|
||||
headerRow: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
screenTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#2E3142',
|
||||
marginBottom: 6,
|
||||
},
|
||||
screenSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6F7D87',
|
||||
fontWeight: '500',
|
||||
},
|
||||
highlightCard: {
|
||||
marginTop: 28,
|
||||
padding: 20,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 20,
|
||||
elevation: 4,
|
||||
},
|
||||
highlightHeader: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
highlightTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
},
|
||||
highlightSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6F7D87',
|
||||
marginTop: 6,
|
||||
},
|
||||
highlightItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 10,
|
||||
},
|
||||
highlightDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
marginTop: 7,
|
||||
marginRight: 10,
|
||||
},
|
||||
highlightText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#4A5460',
|
||||
lineHeight: 20,
|
||||
},
|
||||
resetRow: {
|
||||
marginTop: 16,
|
||||
},
|
||||
resetHint: {
|
||||
fontSize: 12,
|
||||
color: '#8A96A3',
|
||||
},
|
||||
emptyStateCard: {
|
||||
borderRadius: 28,
|
||||
padding: 24,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 16 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 24,
|
||||
elevation: 6,
|
||||
marginBottom: 20,
|
||||
},
|
||||
emptyStateHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyStateSubtitle: {
|
||||
fontSize: 16,
|
||||
color: '#6F7D87',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
},
|
||||
emptyStateContent: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
emptyStateIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: 'rgba(111, 125, 135, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
emptyStateDescription: {
|
||||
fontSize: 15,
|
||||
color: '#4A5460',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
defaultPlanInfo: {
|
||||
fontSize: 13,
|
||||
color: '#8A96A3',
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
paddingHorizontal: 20,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
emptyStateActions: {
|
||||
gap: 12,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: '#2E3142',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: 'transparent',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#2E3142',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#2E3142',
|
||||
},
|
||||
referencesSection: {
|
||||
marginTop: 24,
|
||||
marginBottom: 20,
|
||||
},
|
||||
referencesButton: {
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
referencesGlass: {
|
||||
borderRadius: 20,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
referencesFallback: {
|
||||
backgroundColor: 'rgba(246, 248, 250, 0.8)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(46, 49, 66, 0.1)',
|
||||
},
|
||||
referencesContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
referencesText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#2E3142',
|
||||
marginLeft: 12,
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
679
app/(tabs)/medications.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { MedicationCard } from '@/components/medication/MedicationCard';
|
||||
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
||||
import { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
|
||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||
import { getItemSync, setItemSync } from '@/utils/kvStore';
|
||||
import { convertMedicationDataToWidget, refreshWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 本地存储键名:医疗免责声明已读状态
|
||||
const MEDICAL_DISCLAIMER_READ_KEY = 'medical_disclaimer_read';
|
||||
|
||||
type MedicationFilter = 'all' | 'taken' | 'missed';
|
||||
|
||||
type ThemeColors = (typeof Colors)[keyof typeof Colors];
|
||||
|
||||
export default function MedicationsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colors: ThemeColors = Colors[theme];
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
const { ensureLoggedIn, isLoggedIn } = useAuthGuard();
|
||||
const { checkServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
||||
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
||||
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
|
||||
const celebrationRef = useRef<CelebrationAnimationRef>(null);
|
||||
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
|
||||
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
|
||||
const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false);
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||||
|
||||
// 使用 useMemo 缓存 selector 实例,避免每次渲染都创建新的 selector
|
||||
const medicationSelector = useMemo(
|
||||
() => selectMedicationDisplayItemsByDate(selectedKey),
|
||||
[selectedKey]
|
||||
);
|
||||
const medicationsForDay = useAppSelector(medicationSelector);
|
||||
|
||||
// 直接跳转到 AI 相机页面
|
||||
const handleAddMedication = useCallback(async () => {
|
||||
// 先检查登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 检查 VIP 权限
|
||||
const access = checkServiceAccess();
|
||||
if (!access.canUseService) {
|
||||
openMembershipModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接跳转到 AI 相机页面
|
||||
router.push('/medications/ai-camera');
|
||||
}, [checkServiceAccess, ensureLoggedIn, openMembershipModal]);
|
||||
|
||||
const handleManualAdd = useCallback(() => {
|
||||
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
|
||||
setPendingAction('manual');
|
||||
|
||||
if (hasRead === 'true') {
|
||||
setPendingAction(null);
|
||||
router.push('/medications/add-medication');
|
||||
} else {
|
||||
setDisclaimerVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDisclaimerConfirm = useCallback(() => {
|
||||
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
|
||||
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
|
||||
setDisclaimerVisible(false);
|
||||
if (pendingAction === 'manual') {
|
||||
setPendingAction(null);
|
||||
router.push('/medications/add-medication');
|
||||
}
|
||||
}, [pendingAction]);
|
||||
|
||||
const handleDisclaimerClose = useCallback(() => {
|
||||
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
|
||||
setDisclaimerVisible(false);
|
||||
setPendingAction(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenAiSummary = useCallback(async () => {
|
||||
// 先检查登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 检查 VIP 权限
|
||||
const access = checkServiceAccess();
|
||||
if (!access.canUseService) {
|
||||
// 非会员显示介绍弹窗
|
||||
setAiSummaryInfoVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 会员直接跳转到 AI 总结页面
|
||||
router.push('/medications/ai-summary');
|
||||
}, [checkServiceAccess, ensureLoggedIn]);
|
||||
|
||||
const handleAiSummaryInfoConfirm = useCallback(() => {
|
||||
setAiSummaryInfoVisible(false);
|
||||
// 点击"我要订阅"后,弹出会员订阅弹窗
|
||||
openMembershipModal();
|
||||
}, [openMembershipModal]);
|
||||
|
||||
const handleAiSummaryInfoClose = useCallback(() => {
|
||||
setAiSummaryInfoVisible(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationManagement = useCallback(() => {
|
||||
router.push('/medications/manage-medications');
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
|
||||
router.push({
|
||||
pathname: '/medications/[medicationId]',
|
||||
params: { medicationId },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMedicationTakenCelebration = useCallback(() => {
|
||||
if (celebrationTimerRef.current) {
|
||||
clearTimeout(celebrationTimerRef.current);
|
||||
}
|
||||
|
||||
setIsCelebrationVisible(true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
celebrationRef.current?.play();
|
||||
});
|
||||
|
||||
celebrationTimerRef.current = setTimeout(() => {
|
||||
setIsCelebrationVisible(false);
|
||||
}, 2400);
|
||||
}, []);
|
||||
|
||||
// 加载药物和记录数据
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
dispatch(fetchMedications());
|
||||
dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||
}, [dispatch, selectedKey, isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (celebrationTimerRef.current) {
|
||||
clearTimeout(celebrationTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 重新安排药品通知并刷新数据
|
||||
const refreshDataAndRescheduleNotifications = async () => {
|
||||
try {
|
||||
// 只获取一次药物数据,然后复用结果
|
||||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||||
|
||||
// 获取药物记录
|
||||
const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||
|
||||
// 同步数据到小组件(仅同步今天的)
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const records = recordsAction.payload as any;
|
||||
if (selectedKey === today && records?.records) {
|
||||
const medicationData = convertMedicationDataToWidget(
|
||||
records.records,
|
||||
medications,
|
||||
selectedKey
|
||||
);
|
||||
await syncMedicationDataToWidget(medicationData);
|
||||
|
||||
// 刷新小组件
|
||||
await refreshWidget();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新数据或重新安排药品通知失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
refreshDataAndRescheduleNotifications();
|
||||
}, [dispatch, selectedKey, isLoggedIn])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveFilter('all');
|
||||
}, [selectedDate]);
|
||||
|
||||
// 为每个药物添加默认图片(如果没有图片)
|
||||
const medicationsWithImages = useMemo(() => {
|
||||
return medicationsForDay.map((med: any) => ({
|
||||
...med,
|
||||
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标
|
||||
}));
|
||||
}, [medicationsForDay]);
|
||||
|
||||
const filteredMedications = useMemo(() => {
|
||||
if (activeFilter === 'all') {
|
||||
return medicationsWithImages;
|
||||
}
|
||||
|
||||
// "未服用" tab 包含 missed(已错过)和 upcoming(待服用)两种状态
|
||||
if (activeFilter === 'missed') {
|
||||
return medicationsWithImages.filter((item: any) =>
|
||||
item.status === 'missed' || item.status === 'upcoming'
|
||||
);
|
||||
}
|
||||
|
||||
// 其他状态按原逻辑过滤
|
||||
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
|
||||
}, [activeFilter, medicationsWithImages]);
|
||||
|
||||
const activeMedications = useMemo(() => {
|
||||
if (activeFilter !== 'all') return filteredMedications;
|
||||
return filteredMedications.filter((item: any) => item.status !== 'taken' && item.status !== 'skipped');
|
||||
}, [activeFilter, filteredMedications]);
|
||||
|
||||
const completedMedications = useMemo(() => {
|
||||
if (activeFilter !== 'all') return [];
|
||||
return filteredMedications.filter((item: any) => item.status === 'taken' || item.status === 'skipped');
|
||||
}, [activeFilter, filteredMedications]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
|
||||
// "未服用"计数包含 missed(已错过)和 upcoming(待服用)
|
||||
const missed = medicationsWithImages.filter((item: any) =>
|
||||
item.status === 'missed' || item.status === 'upcoming'
|
||||
).length;
|
||||
return {
|
||||
all: medicationsWithImages.length,
|
||||
taken,
|
||||
missed,
|
||||
};
|
||||
}, [medicationsWithImages]);
|
||||
|
||||
const displayName = userProfile.name?.trim() || DEFAULT_MEMBER_NAME;
|
||||
const headerDateLabel = selectedDate.isSame(dayjs(), 'day')
|
||||
? t('medications.dateFormats.today', { date: selectedDate.format('M月D日') })
|
||||
: t('medications.dateFormats.other', { date: selectedDate.format('M月D日 dddd') });
|
||||
|
||||
const emptyState = filteredMedications.length === 0;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isCelebrationVisible ? (
|
||||
<CelebrationAnimation ref={celebrationRef} visible={isCelebrationVisible} />
|
||||
) : null}
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#edf4f4ff', '#ffffff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingTop: insets.top + 24, paddingBottom: insets.bottom + 36 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<ThemedText style={styles.greeting}>{t('medications.greeting', { name: displayName })}</ThemedText>
|
||||
<ThemedText style={[styles.welcome, { color: colors.textMuted }]}>
|
||||
{t('medications.welcome')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAiSummary}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.36)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="sparkles" size={18} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAiSummary}
|
||||
style={[styles.headerAddButton, styles.fallbackAddButton]}
|
||||
>
|
||||
<IconSymbol name="sparkles" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenMedicationManagement}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenMedicationManagement}
|
||||
style={[styles.headerAddButton, styles.fallbackAddButton]}
|
||||
>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleAddMedication}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleAddMedication}
|
||||
style={[styles.headerAddButton, styles.fallbackAddButton]}
|
||||
>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionSpacing}>
|
||||
<DateSelector
|
||||
selectedIndex={selectedDateIndex}
|
||||
onDateSelect={(index, date) => {
|
||||
setSelectedDate(dayjs(date));
|
||||
setSelectedDateIndex(index);
|
||||
}}
|
||||
disableFutureDates
|
||||
containerStyle={styles.dateSelectorContainer}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionSpacing}>
|
||||
<ThemedText style={styles.sectionHeader}>{t('medications.todayMedications')}</ThemedText>
|
||||
<View style={[styles.segmentedControl, { backgroundColor: colors.surface }]}>
|
||||
{(['all', 'taken', 'missed'] as MedicationFilter[]).map((filter) => {
|
||||
const isActive = activeFilter === filter;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={filter}
|
||||
onPress={() => setActiveFilter(filter)}
|
||||
style={[
|
||||
styles.segment,
|
||||
isActive && { backgroundColor: colors.primary },
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.segmentLabel,
|
||||
{ color: isActive ? colors.onPrimary : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{t(`medications.filters.${filter}`)}
|
||||
</ThemedText>
|
||||
<View
|
||||
style={[
|
||||
styles.segmentBadge,
|
||||
{
|
||||
backgroundColor: isActive ? colors.onPrimary : `${colors.primary}20`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.segmentBadgeText,
|
||||
{ color: isActive ? colors.primary : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{counts[filter]}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{emptyState ? (
|
||||
<View style={[styles.emptyState, { backgroundColor: colors.surface }]}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/ImageEmpty.png')}
|
||||
style={styles.emptyIllustration}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<ThemedText style={styles.emptyTitle}>{t('medications.emptyState.title')}</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
||||
{t('medications.emptyState.subtitle')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.cardsWrapper}>
|
||||
{/* 渲染未服用的药物 */}
|
||||
{activeMedications.map((item: any) => (
|
||||
<MedicationCard
|
||||
key={item.id}
|
||||
medication={item}
|
||||
colors={colors}
|
||||
selectedDate={selectedDate}
|
||||
onOpenDetails={() => handleOpenMedicationDetails(item.medicationId)}
|
||||
onCelebrate={handleMedicationTakenCelebration}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 渲染已完成(服用/跳过)的药物堆叠 */}
|
||||
{completedMedications.length > 0 && (
|
||||
<TakenMedicationsStack
|
||||
medications={completedMedications}
|
||||
colors={colors}
|
||||
selectedDate={selectedDate}
|
||||
onOpenDetails={(item) => handleOpenMedicationDetails(item.medicationId)}
|
||||
onCelebrate={handleMedicationTakenCelebration}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 医疗免责声明弹窗 */}
|
||||
<MedicalDisclaimerSheet
|
||||
visible={disclaimerVisible}
|
||||
onClose={handleDisclaimerClose}
|
||||
onConfirm={handleDisclaimerConfirm}
|
||||
/>
|
||||
|
||||
{/* AI 用药总结介绍弹窗 */}
|
||||
<MedicationAiSummaryInfoSheet
|
||||
visible={aiSummaryInfoVisible}
|
||||
onClose={handleAiSummaryInfoClose}
|
||||
onConfirm={handleAiSummaryInfoConfirm}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
headerAddButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackAddButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
avatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
welcome: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
sectionSpacing: {
|
||||
gap: 16,
|
||||
},
|
||||
dateSelectorContainer: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
segmentedControl: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 18,
|
||||
padding: 6,
|
||||
gap: 6,
|
||||
},
|
||||
segment: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
segmentLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
segmentBadge: {
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
segmentBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 32,
|
||||
borderRadius: 24,
|
||||
gap: 16,
|
||||
},
|
||||
emptyIllustration: {
|
||||
width: 160,
|
||||
height: 160,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
emptyTitle: {
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
primaryButton: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 22,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
cardsWrapper: {
|
||||
gap: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 48,
|
||||
borderRadius: 24,
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
986
app/(tabs)/statistics.tsx
Normal file
@@ -0,0 +1,986 @@
|
||||
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 { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHealthDataForDate } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { t } = useTranslation();
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||
const router = useRouter();
|
||||
|
||||
// 使用 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]);
|
||||
|
||||
const handleOpenGallery = React.useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/gallery');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
// 用于触发动画重置的 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]);
|
||||
|
||||
// 同步 HealthKit 数据到服务端(带智能 diff 比较)
|
||||
const syncHealthDataToServer = React.useCallback(async () => {
|
||||
if (!isLoggedIn || !userProfile) {
|
||||
logger.info('用户未登录,跳过 HealthKit 数据同步');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
|
||||
|
||||
// 传入当前用户资料,用于 diff 比较
|
||||
const success = await syncHealthKitToServer(
|
||||
async (data) => {
|
||||
await dispatch(updateUserProfile(data) as any);
|
||||
},
|
||||
userProfile // 传入当前用户资料进行比较
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logger.info('HealthKit 数据同步到服务端成功');
|
||||
} else {
|
||||
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('同步 HealthKit 数据到服务端失败:', error);
|
||||
}
|
||||
}, [isLoggedIn, dispatch, userProfile]);
|
||||
|
||||
// 初始加载时执行数据加载和同步
|
||||
useEffect(() => {
|
||||
loadAllData(currentSelectedDate);
|
||||
|
||||
// 延迟1秒后执行同步,避免影响初始加载性能
|
||||
const syncTimer = setTimeout(() => {
|
||||
syncHealthDataToServer();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(syncTimer);
|
||||
}, [])
|
||||
|
||||
|
||||
// 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', '#edf4f4ff', '#f7f8f8ff']}
|
||||
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}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Image
|
||||
source={require('@/assets/machine.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 右边文字区域 */}
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={handleOpenGallery}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.liquidGlassButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
|
||||
{/* 日期选择器 */}
|
||||
<DateSelector
|
||||
selectedIndex={selectedIndex}
|
||||
onDateSelect={onSelectDate}
|
||||
showMonthTitle={false}
|
||||
disableFutureDates={true}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
|
||||
|
||||
<WorkoutSummaryCard
|
||||
date={currentSelectedDate}
|
||||
style={styles.workoutCardOverride}
|
||||
/>
|
||||
|
||||
{/* 身体指标section标题 */}
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{t('statistics.sections.bodyMetrics')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 真正瀑布流布局 */}
|
||||
<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
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
</View>
|
||||
</View>
|
||||
<WeightHistoryCard />
|
||||
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<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',
|
||||
gap: 12,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
logoImage: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 20,
|
||||
},
|
||||
headerTextContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
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,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
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',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
caloriesUnit: {
|
||||
color: '#515558ff',
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
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',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
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',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
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',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
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',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
stepsValue: {
|
||||
fontSize: 14,
|
||||
color: '#7A6A42',
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFE5E5',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
color: '#E54D4D',
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
retryButton: {
|
||||
padding: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
viewMoreContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
viewMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
viewMoreIcon: {
|
||||
fontSize: 16,
|
||||
color: '#192126',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
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
|
||||
},
|
||||
workoutCardOverride: {
|
||||
marginTop: 16,
|
||||
},
|
||||
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,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
addWeightButton: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
padding: 4,
|
||||
},
|
||||
circumferenceCard: {
|
||||
marginBottom: 36,
|
||||
marginTop: 16
|
||||
},
|
||||
sectionHeader: {
|
||||
marginTop: 24,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
reportButton: {
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: '#F6F7FB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
reportIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reportButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#0F172A',
|
||||
},
|
||||
// Liquid Glass 风格按钮
|
||||
liquidGlassButton: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
liquidGlassFallback: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
516
app/_layout.tsx
@@ -1,45 +1,489 @@
|
||||
import '@/i18n';
|
||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||
import { hrvMonitorService } from '@/services/hrvMonitor';
|
||||
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
|
||||
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
||||
import { setupQuickActions } from '@/services/quickActions';
|
||||
import { sleepMonitorService } from '@/services/sleepMonitor';
|
||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||
import { WaterRecordSource } from '@/services/waterRecords';
|
||||
import { workoutMonitorService } from '@/services/workoutMonitor';
|
||||
import { store } from '@/store';
|
||||
import { rehydrateUser, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import React from 'react';
|
||||
import RNExitApp from 'react-native-exit-app';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||
import { fetchMyProfile, logout, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { loadActiveFastingSchedule } from '@/utils/fasting';
|
||||
import { initializeHealthPermissions } from '@/utils/health';
|
||||
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
|
||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
|
||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { fetchChallenges } from '@/store/challengesSlice';
|
||||
import { loadTabBarConfigs } from '@/store/tabBarConfigSlice';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// 在开发环境中导入调试工具
|
||||
let BackgroundTaskDebugger: any = null;
|
||||
if (__DEV__) {
|
||||
try {
|
||||
const debuggerModule = require('@/services/backgroundTaskDebugger');
|
||||
BackgroundTaskDebugger = debuggerModule.BackgroundTaskDebugger;
|
||||
} catch (error) {
|
||||
logger.warn('无法导入后台任务调试工具:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { privacyAgreed } = useAppSelector((state) => state.user);
|
||||
const router = useRouter();
|
||||
const { profile, onboardingCompleted } = useAppSelector((state) => state.user);
|
||||
const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
|
||||
const [userDataLoaded, setUserDataLoaded] = React.useState(false);
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
const fastingHydrationRequestedRef = React.useRef(false);
|
||||
const permissionInitializedRef = React.useRef(false);
|
||||
|
||||
// 初始化快捷动作处理
|
||||
useQuickActions();
|
||||
|
||||
// 注册401未授权处理器(应用启动时执行一次)
|
||||
React.useEffect(() => {
|
||||
const loadUserData = async () => {
|
||||
await dispatch(rehydrateUser());
|
||||
setUserDataLoaded(true);
|
||||
const handle401 = async () => {
|
||||
try {
|
||||
logger.info('[401处理] 开始处理登录过期');
|
||||
|
||||
// 清除Redux状态
|
||||
await dispatch(logout());
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/auth/login');
|
||||
|
||||
logger.info('[401处理] 登录过期处理完成');
|
||||
} catch (error) {
|
||||
logger.error('[401处理] 处理失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadUserData();
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
}, [dispatch]);
|
||||
setUnauthorizedHandler(handle401);
|
||||
logger.info('[401处理器] 已注册到API服务');
|
||||
}, [dispatch, router]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
|
||||
if (userDataLoaded && !privacyAgreed) {
|
||||
setShowPrivacyModal(true);
|
||||
if (fastingHydrationRequestedRef.current) return;
|
||||
if (activeFastingSchedule) {
|
||||
fastingHydrationRequestedRef.current = true;
|
||||
return;
|
||||
}
|
||||
}, [userDataLoaded, privacyAgreed]);
|
||||
|
||||
fastingHydrationRequestedRef.current = true;
|
||||
let cancelled = false;
|
||||
|
||||
const hydrate = async () => {
|
||||
try {
|
||||
const stored = await loadActiveFastingSchedule();
|
||||
if (cancelled || !stored) return;
|
||||
if (store.getState().fasting.activeSchedule) return;
|
||||
dispatch(hydrateActiveSchedule(stored));
|
||||
} catch (error) {
|
||||
logger.warn('恢复断食计划失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
hydrate();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dispatch, activeFastingSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
dispatch(fetchChallenges());
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
// 初始化底部栏配置
|
||||
useEffect(() => {
|
||||
dispatch(loadTabBarConfigs());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 基础服务初始化(不需要权限,总是执行)====================
|
||||
React.useEffect(() => {
|
||||
const initializeBasicServices = async () => {
|
||||
try {
|
||||
logger.info('🚀 开始初始化基础服务(不需要权限)...');
|
||||
|
||||
if (isLoggedIn) {
|
||||
// 1. 加载用户数据(首屏展示需要)
|
||||
await dispatch(fetchMyProfile());
|
||||
logger.info('✅ 用户数据加载完成');
|
||||
}
|
||||
|
||||
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
|
||||
initializeHealthPermissions();
|
||||
logger.info('✅ HealthKit 权限系统初始化完成');
|
||||
|
||||
// 3. 初始化快捷动作(用户可能立即使用)
|
||||
await setupQuickActions();
|
||||
logger.info('✅ 快捷动作初始化完成');
|
||||
|
||||
// 5. 初始化喝水记录 Bridge
|
||||
initializeWaterRecordBridge();
|
||||
logger.info('✅ 喝水记录 Bridge 初始化完成');
|
||||
|
||||
logger.info('🎉 基础服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 基础服务初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeBasicServices();
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 应用状态监听 - 进入前台时清除角标 ====================
|
||||
React.useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === 'active') {
|
||||
// 应用进入前台时清除角标
|
||||
clearBadgeCount();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ==================== 权限相关服务初始化(应用启动时执行)====================
|
||||
React.useEffect(() => {
|
||||
// 如果已经初始化过,则跳过(确保只初始化一次)
|
||||
if (permissionInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
permissionInitializedRef.current = true;
|
||||
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// 异步同步 Widget 数据(不阻塞主流程)
|
||||
const syncWidgetDataInBackground = async () => {
|
||||
try {
|
||||
const widgetSync = await syncPendingWidgetChanges();
|
||||
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
||||
logger.info(`🔄 检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||
|
||||
// 异步处理每条记录
|
||||
for (const record of widgetSync.pendingRecords) {
|
||||
try {
|
||||
await store.dispatch(createWaterRecordAction({
|
||||
amount: record.amount,
|
||||
recordedAt: record.recordedAt,
|
||||
source: WaterRecordSource.Auto,
|
||||
})).unwrap();
|
||||
|
||||
logger.info(`✅ 成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
||||
} catch (error) {
|
||||
logger.error('❌ 同步水记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除已同步的记录
|
||||
await clearPendingWaterRecords();
|
||||
logger.info('✅ 所有待同步的水记录已处理完成');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Widget 数据同步失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 批量注册所有通知提醒
|
||||
const registerAllNotifications = async () => {
|
||||
try {
|
||||
logger.info('📢 开始批量注册通知提醒...');
|
||||
|
||||
// 获取用户偏好设置
|
||||
const [nutritionReminderEnabled, moodReminderEnabled, waterSettings] = await Promise.all([
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
getWaterReminderSettings(),
|
||||
]);
|
||||
|
||||
// 准备所有通知注册任务
|
||||
const notificationTasks = [];
|
||||
|
||||
// 营养提醒 - 根据用户设置决定是否注册
|
||||
if (nutritionReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 午餐提醒已注册')
|
||||
),
|
||||
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 晚餐提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启营养提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 心情提醒 - 根据用户设置决定是否注册
|
||||
if (moodReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 心情提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启心情提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 喝水提醒 - 根据用户设置决定是否注册
|
||||
if (waterSettings.enabled) {
|
||||
notificationTasks.push(
|
||||
WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', waterSettings).then(() =>
|
||||
logger.info('✅ 自定义喝水提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启喝水提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 并行执行所有通知注册任务
|
||||
if (notificationTasks.length > 0) {
|
||||
await Promise.all(notificationTasks);
|
||||
}
|
||||
|
||||
// 检查断食通知(如果有活跃计划)
|
||||
const fastingSchedule = store.getState().fasting.activeSchedule;
|
||||
if (fastingSchedule) {
|
||||
logger.info('✅ 检测到活跃的断食计划,将通过页面 hook 自动安排通知');
|
||||
}
|
||||
|
||||
logger.info('🎉 所有通知提醒注册完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 通知提醒注册失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化后台任务管理器
|
||||
const initializeBackgroundTaskManager = async () => {
|
||||
try {
|
||||
logger.info('⚙️ 初始化后台任务管理器...');
|
||||
|
||||
await BackgroundTaskManager.getInstance().initialize();
|
||||
logger.info('✅ 后台任务管理器初始化成功');
|
||||
|
||||
// 简单的任务调度检查
|
||||
const taskManager = BackgroundTaskManager.getInstance();
|
||||
const status = await taskManager.getStatus();
|
||||
|
||||
if (status === 'available') {
|
||||
const pendingRequests = await taskManager.getPendingRequests();
|
||||
if (pendingRequests.length === 0) {
|
||||
await taskManager.scheduleNextTask();
|
||||
logger.info('✅ 已调度新的后台任务');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ 后台任务管理器初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化健康监听服务(锻炼 + 睡眠)
|
||||
const initializeHealthMonitoring = async () => {
|
||||
try {
|
||||
logger.info('💪 初始化健康监听服务...');
|
||||
|
||||
const [workoutResult, sleepResult, hrvResult] = await Promise.allSettled([
|
||||
workoutMonitorService.initialize(),
|
||||
sleepMonitorService.initialize(),
|
||||
hrvMonitorService.initialize(),
|
||||
]);
|
||||
|
||||
const workoutReady = workoutResult.status === 'fulfilled';
|
||||
if (workoutReady) {
|
||||
logger.info('✅ 锻炼监听服务初始化成功');
|
||||
} else {
|
||||
logger.error('❌ 锻炼监听服务初始化失败:', workoutResult.reason);
|
||||
}
|
||||
|
||||
const sleepReady = sleepResult.status === 'fulfilled';
|
||||
if (sleepReady) {
|
||||
logger.info('✅ 睡眠监听服务初始化成功');
|
||||
} else {
|
||||
logger.error('❌ 睡眠监听服务初始化失败:', sleepResult.reason);
|
||||
}
|
||||
|
||||
const hrvReady = hrvResult.status === 'fulfilled';
|
||||
if (hrvReady) {
|
||||
logger.info('✅ HRV 监听服务初始化成功');
|
||||
} else {
|
||||
logger.error('❌ HRV 监听服务初始化失败:', hrvResult.reason);
|
||||
}
|
||||
|
||||
if (workoutReady && sleepReady && hrvReady) {
|
||||
logger.info('🎉 健康监听服务初始化完成');
|
||||
} else {
|
||||
logger.warn('⚠️ 健康监听服务部分未能初始化成功,请检查上述错误日志');
|
||||
}
|
||||
|
||||
return workoutReady && sleepReady && hrvReady;
|
||||
} catch (error) {
|
||||
logger.error('❌ 健康监听服务初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 后台任务详细状态检查(空闲时执行)
|
||||
const checkBackgroundTaskStatus = async () => {
|
||||
try {
|
||||
logger.info('🔍 检查后台任务详细状态...');
|
||||
|
||||
const taskManager = BackgroundTaskManager.getInstance();
|
||||
const status = await taskManager.getStatus();
|
||||
const statusText = await taskManager.checkStatus();
|
||||
|
||||
logger.info(`📊 后台任务状态: ${status} (${statusText})`);
|
||||
|
||||
// 检查上次执行时间
|
||||
const lastCheckTime = await taskManager.getLastBackgroundCheckTime();
|
||||
if (lastCheckTime) {
|
||||
const timeSinceLastCheck = Date.now() - lastCheckTime;
|
||||
const hoursSinceLastCheck = timeSinceLastCheck / (1000 * 60 * 60);
|
||||
logger.info(`⏱️ 上次执行: ${new Date(lastCheckTime).toLocaleString()} (${hoursSinceLastCheck.toFixed(1)}小时前)`);
|
||||
|
||||
if (hoursSinceLastCheck > 24) {
|
||||
logger.warn('⚠️ 超过24小时未执行后台任务,请检查系统设置');
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('✅ 后台任务状态检查完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 后台任务状态检查失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 权限服务初始化
|
||||
const initializePermissionServices = async () => {
|
||||
try {
|
||||
logger.info('🔐 开始初始化需要权限的服务...');
|
||||
|
||||
// 1. 初始化通知服务(包含权限请求)
|
||||
await notificationService.initialize();
|
||||
logger.info('✅ 通知服务初始化完成');
|
||||
|
||||
// 2. 清理旧的药品本地通知(迁移到服务端推送)
|
||||
cleanupLegacyMedicationNotifications().catch(error => {
|
||||
logger.error('❌ 清理旧药品通知失败:', error);
|
||||
});
|
||||
|
||||
// 3. 异步同步 Widget 数据(不阻塞主流程)
|
||||
syncWidgetDataInBackground();
|
||||
|
||||
logger.info('🎉 权限相关服务初始化完成');
|
||||
logger.info('💡 HealthKit 权限将在用户首次访问健康数据时请求');
|
||||
} catch (error) {
|
||||
logger.error('❌ 权限相关服务初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 后台服务初始化(延迟执行)====================
|
||||
const initializeBackgroundServices = () => {
|
||||
const { InteractionManager } = require('react-native');
|
||||
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
logger.info('📅 开始初始化后台服务...');
|
||||
|
||||
// 1. 批量注册所有通知提醒
|
||||
await registerAllNotifications();
|
||||
|
||||
// 2. 初始化后台任务管理器
|
||||
await initializeBackgroundTaskManager();
|
||||
|
||||
// 3. 初始化健康监听服务
|
||||
await initializeHealthMonitoring();
|
||||
|
||||
logger.info('🎉 后台服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 后台服务初始化失败:', error);
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 空闲服务初始化====================
|
||||
const initializeIdleServices = () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
logger.info('🔄 开始初始化空闲服务...');
|
||||
|
||||
// 1. 后台任务详细状态检查
|
||||
await checkBackgroundTaskStatus();
|
||||
|
||||
// 2. 开发环境调试工具
|
||||
if (__DEV__ && BackgroundTaskDebugger) {
|
||||
logger.info('✅ 后台任务调试工具未初始化(开发环境)');
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('🎉 空闲服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 空闲服务初始化失败:', error);
|
||||
}
|
||||
}, 8000);
|
||||
};
|
||||
|
||||
const runInitializationSequence = async () => {
|
||||
try {
|
||||
await initializePermissionServices();
|
||||
} catch {
|
||||
logger.warn('⚠️ 权限相关服务初始化失败,将继续启动后台和空闲服务以便后续重试');
|
||||
}
|
||||
|
||||
// 交互完成后执行后台服务
|
||||
initializeBackgroundServices();
|
||||
|
||||
// 空闲时执行非关键服务
|
||||
initializeIdleServices();
|
||||
};
|
||||
|
||||
runInitializationSequence();
|
||||
|
||||
}, []); // 每次应用启动都执行,不依赖其他状态
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
const getPrivacyAgreed = async () => {
|
||||
const str = await AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed)
|
||||
|
||||
setShowPrivacyModal(str !== 'true');
|
||||
}
|
||||
getPrivacyAgreed();
|
||||
}, []);
|
||||
|
||||
const handlePrivacyAgree = () => {
|
||||
dispatch(setPrivacyAgreed());
|
||||
@@ -47,26 +491,28 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const handlePrivacyDisagree = () => {
|
||||
RNExitApp.exitApp();
|
||||
// RNExitApp.exitApp();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogProvider>
|
||||
<MembershipModalProvider>
|
||||
{children}
|
||||
<PrivacyConsentModal
|
||||
visible={showPrivacyModal}
|
||||
onAgree={handlePrivacyAgree}
|
||||
onDisagree={handlePrivacyDisagree}
|
||||
/>
|
||||
<Toast />
|
||||
</MembershipModalProvider>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
AliRegular: require('../assets/fonts/ali-regular.ttf'),
|
||||
AliBold: require('../assets/fonts/ali-bold.ttf'),
|
||||
});
|
||||
|
||||
if (!loaded) {
|
||||
@@ -75,29 +521,39 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<Provider store={store}>
|
||||
<Bootstrapper>
|
||||
<ToastProvider>
|
||||
<VersionCheckProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<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="ai-coach-chat" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||
|
||||
<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="workout/notification-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="health-data-permissions"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
<Toast />
|
||||
</ThemeProvider>
|
||||
</VersionCheckProvider>
|
||||
</ToastProvider>
|
||||
</Bootstrapper>
|
||||
</Provider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Linking,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
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';
|
||||
|
||||
type UploadState = {
|
||||
front?: string | null;
|
||||
side?: string | null;
|
||||
back?: string | null;
|
||||
};
|
||||
|
||||
type Sample = { uri: string; correct: boolean };
|
||||
|
||||
const SAMPLES: Record<PoseView, Sample[]> = {
|
||||
front: [
|
||||
{ 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://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://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 },
|
||||
],
|
||||
};
|
||||
|
||||
export default function AIPostureAssessmentScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = Colors.light;
|
||||
|
||||
const [uploadState, setUploadState] = useState<UploadState>({});
|
||||
const canStart = useMemo(
|
||||
() => Boolean(uploadState.front && uploadState.side && uploadState.back),
|
||||
[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);
|
||||
const [cameraCanAsk, setCameraCanAsk] = useState<boolean | null>(null);
|
||||
const [libraryCanAsk, setLibraryCanAsk] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const cam = await ImagePicker.getCameraPermissionsAsync();
|
||||
const lib = await ImagePicker.getMediaLibraryPermissionsAsync();
|
||||
setCameraPerm(cam.status);
|
||||
setLibraryPerm(lib.status);
|
||||
setLibraryAccess(
|
||||
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
|
||||
);
|
||||
setCameraCanAsk(cam.canAskAgain);
|
||||
setLibraryCanAsk(lib.canAskAgain);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function requestAllPermissions() {
|
||||
try {
|
||||
const cam = await ImagePicker.requestCameraPermissionsAsync();
|
||||
const lib = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
setCameraPerm(cam.status);
|
||||
setLibraryPerm(lib.status);
|
||||
setLibraryAccess(
|
||||
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
|
||||
);
|
||||
setCameraCanAsk(cam.canAskAgain);
|
||||
setLibraryCanAsk(lib.canAskAgain);
|
||||
const libGranted = lib.status === 'granted' || (lib as any).accessPrivileges === 'limited';
|
||||
if (cam.status !== 'granted' || !libGranted) {
|
||||
Alert.alert(
|
||||
'权限未完全授予',
|
||||
'请在系统设置中授予相机与相册权限以完成上传',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '去设置', onPress: () => Linking.openSettings() },
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function requestPermissionAndPick(source: 'camera' | 'library', key: PoseView) {
|
||||
try {
|
||||
if (source === 'camera') {
|
||||
const resp = await ImagePicker.requestCameraPermissionsAsync();
|
||||
setCameraPerm(resp.status);
|
||||
setCameraCanAsk(resp.canAskAgain);
|
||||
if (resp.status !== 'granted') {
|
||||
Alert.alert(
|
||||
'权限不足',
|
||||
'需要相机权限以拍摄照片',
|
||||
resp.canAskAgain
|
||||
? [{ text: '好的' }]
|
||||
: [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '去设置', onPress: () => Linking.openSettings() },
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.8,
|
||||
aspect: [3, 4],
|
||||
});
|
||||
if (!result.canceled) {
|
||||
// 设置正在上传状态
|
||||
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();
|
||||
setLibraryPerm(resp.status);
|
||||
setLibraryAccess(
|
||||
(resp as any).accessPrivileges ?? (resp.status === 'granted' ? 'all' : 'none')
|
||||
);
|
||||
setLibraryCanAsk(resp.canAskAgain);
|
||||
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
|
||||
if (!libGranted) {
|
||||
Alert.alert(
|
||||
'权限不足',
|
||||
'需要相册权限以选择照片',
|
||||
resp.canAskAgain
|
||||
? [{ text: '好的' }]
|
||||
: [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '去设置', onPress: () => Linking.openSettings() },
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.8,
|
||||
aspect: [3, 4],
|
||||
});
|
||||
if (!result.canceled) {
|
||||
// 设置正在上传状态
|
||||
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) {
|
||||
Alert.alert('发生错误', '选择图片失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
function handleStart() {
|
||||
if (!canStart) return;
|
||||
// 进入评估中间页面
|
||||
router.push('/ai-posture-processing');
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Permissions Banner (iOS 优先提示) */}
|
||||
{Platform.OS === 'ios' && (
|
||||
(cameraPerm !== 'granted' || !(libraryPerm === 'granted' || libraryAccess === 'limited')) && (
|
||||
<BlurView intensity={18} tint="dark" style={styles.permBanner}>
|
||||
<Text style={styles.permTitle}>需要相机与相册权限</Text>
|
||||
<Text style={styles.permDesc}>
|
||||
授权后可拍摄或选择三视角全身照片用于AI体态测评。
|
||||
</Text>
|
||||
<View style={styles.permActions}>
|
||||
{((cameraCanAsk ?? true) || (libraryCanAsk ?? true)) ? (
|
||||
<TouchableOpacity style={styles.permPrimary} onPress={requestAllPermissions}>
|
||||
<Text style={styles.permPrimaryText}>一键授权</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity style={styles.permPrimary} onPress={() => Linking.openSettings()}>
|
||||
<Text style={styles.permPrimaryText}>去设置开启</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={styles.permSecondary} onPress={() => requestPermissionAndPick('library', 'front')}>
|
||||
<Text style={styles.permSecondaryText}>稍后再说</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurView>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Intro */}
|
||||
<View style={styles.introBox}>
|
||||
<Text style={[styles.title, { color: '#192126' }]}>上传标准姿势照片</Text>
|
||||
<Text style={[styles.description, { color: '#5E6468' }]}>请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。</Text>
|
||||
</View>
|
||||
|
||||
{/* Upload sections */}
|
||||
<UploadTile
|
||||
label="正面"
|
||||
value={uploadState.front}
|
||||
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
|
||||
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
|
||||
samples={SAMPLES.front}
|
||||
uploading={uploading && uploadingKey === 'front'}
|
||||
/>
|
||||
|
||||
<UploadTile
|
||||
label="侧面"
|
||||
value={uploadState.side}
|
||||
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
||||
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
||||
samples={SAMPLES.side}
|
||||
uploading={uploading && uploadingKey === 'side'}
|
||||
/>
|
||||
|
||||
<UploadTile
|
||||
label="背面"
|
||||
value={uploadState.back}
|
||||
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
||||
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
||||
samples={SAMPLES.back}
|
||||
uploading={uploading && uploadingKey === 'back'}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<View style={[styles.bottomCtaWrap, { paddingBottom: insets.bottom + 10 }]}>
|
||||
<TouchableOpacity
|
||||
disabled={!canStart}
|
||||
activeOpacity={1}
|
||||
onPress={handleStart}
|
||||
style={[
|
||||
styles.bottomCta,
|
||||
{ backgroundColor: canStart ? theme.primary : theme.neutral300 },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.bottomCtaText, { color: canStart ? theme.onPrimary : theme.textMuted }]}>
|
||||
{canStart ? '开始测评' : '请先完成三视角上传'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadTile({
|
||||
label,
|
||||
value,
|
||||
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}>
|
||||
<Text style={styles.sectionTitle}>{label}</Text>
|
||||
{value ? (
|
||||
<Text style={styles.retakeHint}>可长按替换</Text>
|
||||
) : (
|
||||
<Text style={styles.retakeHint}>需上传此视角</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.95}
|
||||
onLongPress={onPickLibrary}
|
||||
onPress={onPickCamera}
|
||||
style={styles.uploader}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
|
||||
<ActivityIndicator size="large" color="#BBF246" />
|
||||
<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="#BBF246" />
|
||||
</View>
|
||||
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
||||
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<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} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
permBanner: {
|
||||
marginTop: 12,
|
||||
marginHorizontal: 16,
|
||||
padding: 14,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(25,33,38,0.06)'
|
||||
},
|
||||
permTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
permDesc: {
|
||||
color: '#5E6468',
|
||||
marginTop: 6,
|
||||
fontSize: 13,
|
||||
},
|
||||
permActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 10,
|
||||
},
|
||||
permPrimary: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 14,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#BBF246',
|
||||
},
|
||||
permPrimaryText: {
|
||||
color: '#192126',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
permSecondary: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 14,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(25,33,38,0.14)',
|
||||
},
|
||||
permSecondaryText: {
|
||||
color: '#384046',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 22,
|
||||
color: '#ECEDEE',
|
||||
fontWeight: '700',
|
||||
},
|
||||
introBox: {
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 20,
|
||||
gap: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 26,
|
||||
color: '#ECEDEE',
|
||||
fontWeight: '800',
|
||||
},
|
||||
description: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
section: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
sectionTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
retakeHint: {
|
||||
color: '#888F92',
|
||||
fontSize: 13,
|
||||
},
|
||||
uploader: {
|
||||
height: 220,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'rgba(25,33,38,0.14)',
|
||||
backgroundColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
preview: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
plusBadge: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderWidth: 2,
|
||||
borderColor: '#BBF246',
|
||||
},
|
||||
placeholderTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
placeholderDesc: {
|
||||
color: '#888F92',
|
||||
fontSize: 12,
|
||||
},
|
||||
sampleBox: {
|
||||
marginTop: 8,
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||
},
|
||||
sampleTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
fontWeight: '600',
|
||||
},
|
||||
sampleRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
sampleItem: {
|
||||
flex: 1,
|
||||
},
|
||||
sampleImg: {
|
||||
width: '100%',
|
||||
height: 90,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F2F4F5',
|
||||
},
|
||||
sampleTag: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
marginTop: 6,
|
||||
},
|
||||
sampleTagText: {
|
||||
color: '#192126',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
bottomCtaWrap: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 0,
|
||||
},
|
||||
bottomCta: {
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
bottomCtaText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
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: '#BBF246',
|
||||
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: '#BBF246',
|
||||
shadowColor: '#BBF246',
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
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('/ai-coach-chat')}>
|
||||
<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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
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 { ActivityIndicator, 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';
|
||||
@@ -12,16 +13,18 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { login } from '@/store/userSlice';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
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 searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string; shouldBack?: string }>();
|
||||
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const color = Colors[scheme];
|
||||
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useI18n();
|
||||
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
|
||||
|
||||
// 背景动效:轻微平移/旋转与呼吸动画
|
||||
@@ -78,12 +81,12 @@ export default function LoginScreen() {
|
||||
const guardAgreement = useCallback((action: () => void) => {
|
||||
if (!hasAgreed) {
|
||||
Alert.alert(
|
||||
'请先阅读并同意',
|
||||
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
|
||||
t('login.agreement.alert.title'),
|
||||
t('login.agreement.alert.message'),
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: t('login.agreement.alert.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: '同意并继续',
|
||||
text: t('login.agreement.alert.confirm'),
|
||||
onPress: () => {
|
||||
setHasAgreed(true);
|
||||
setTimeout(() => action(), 0);
|
||||
@@ -95,7 +98,7 @@ export default function LoginScreen() {
|
||||
return;
|
||||
}
|
||||
action();
|
||||
}, [hasAgreed]);
|
||||
}, [hasAgreed, t]);
|
||||
|
||||
const onAppleLogin = useCallback(async () => {
|
||||
if (!appleAvailable) return;
|
||||
@@ -109,15 +112,25 @@ export default function LoginScreen() {
|
||||
});
|
||||
const identityToken = (credential as any)?.identityToken;
|
||||
if (!identityToken || typeof identityToken !== 'string') {
|
||||
throw new Error('未获取到 Apple 身份令牌');
|
||||
throw new Error(t('login.errors.appleIdentityTokenMissing'));
|
||||
}
|
||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||
|
||||
// 拉取用户信息
|
||||
await dispatch(fetchMyProfile())
|
||||
|
||||
Toast.show({
|
||||
text1: '登录成功',
|
||||
text1: t('login.success.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
// 登录成功后处理重定向
|
||||
const shouldBack = searchParams?.shouldBack === 'true';
|
||||
|
||||
if (shouldBack) {
|
||||
// 如果设置了 shouldBack,直接返回上一页
|
||||
router.back();
|
||||
} else {
|
||||
// 否则按照原有逻辑进行重定向
|
||||
const to = searchParams?.redirectTo as string | undefined;
|
||||
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||||
let parsedParams: Record<string, any> | undefined;
|
||||
@@ -129,29 +142,18 @@ export default function LoginScreen() {
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'ERR_CANCELED') return;
|
||||
const message = err?.message || '登录失败,请稍后再试';
|
||||
Alert.alert('登录失败', message);
|
||||
console.log('err.code', err.code);
|
||||
|
||||
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
|
||||
const message = err?.message || t('login.errors.loginFailed');
|
||||
Alert.alert(t('login.errors.loginFailedTitle'), message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo, dispatch, t]);
|
||||
|
||||
const onGuestLogin = useCallback(() => {
|
||||
// 游客继续:若有 redirect 则前往,无则返回
|
||||
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();
|
||||
}
|
||||
}, [router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||
|
||||
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
|
||||
|
||||
@@ -228,35 +230,83 @@ export default function LoginScreen() {
|
||||
</View>
|
||||
{/* 自定义头部,与其它页面风格一致 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} activeOpacity={0.7}>
|
||||
<GlassView
|
||||
style={styles.backButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={[styles.backButton, styles.fallbackBackground]} activeOpacity={0.7}>
|
||||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: color.text }]}>登录</Text>
|
||||
)}
|
||||
<Text style={[styles.headerTitle, { color: color.text }]}>{t('login.title')}</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerWrap}>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>普拉提助手</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录普拉提星球</ThemedText>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>{t('login.subtitle')}</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Apple 登录 */}
|
||||
{appleAvailable && (
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
onPress={() => guardAgreement(onAppleLogin)}
|
||||
disabled={loading}
|
||||
style={({ pressed }) => [
|
||||
styles.appleButton,
|
||||
{ backgroundColor: '#000000' },
|
||||
loading && { opacity: 0.7 },
|
||||
pressed && { transform: [{ scale: 0.98 }] },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.appleButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(0, 0, 0, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text style={styles.appleText}>{t('login.loggingIn')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.appleButton, styles.appleButtonFallback, loading && { opacity: 0.7 }]}>
|
||||
{loading ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text style={styles.appleText}>{t('login.loggingIn')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 协议勾选 */}
|
||||
@@ -271,13 +321,13 @@ export default function LoginScreen() {
|
||||
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>我已阅读并同意</Text>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.readAndAgree')}</Text>
|
||||
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
||||
<Text style={[styles.link, { color: color.primary }]}>《隐私政策》</Text>
|
||||
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
|
||||
</Pressable>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>和</Text>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.and')}</Text>
|
||||
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
|
||||
<Text style={[styles.link, { color: color.primary }]}>《用户协议》</Text>
|
||||
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -305,7 +355,17 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 4,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
backButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
|
||||
backButton: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 38,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackBackground: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700' },
|
||||
headerWrap: {
|
||||
marginBottom: 36,
|
||||
@@ -328,12 +388,16 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
appleButtonFallback: {
|
||||
backgroundColor: '#000000',
|
||||
},
|
||||
appleText: {
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
|
||||
242
app/badges/index.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { fetchAvailableBadges, selectBadgesLoading, selectSortedBadges } from '@/store/badgesSlice';
|
||||
import { DEFAULT_MEMBER_NAME, selectUserProfile } from '@/store/userSlice';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function BadgesScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const badges = useAppSelector(selectSortedBadges);
|
||||
const loading = useAppSelector(selectBadgesLoading);
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showcaseBadge, setShowcaseBadge] = useState<BadgeDto | null>(null);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(fetchAvailableBadges());
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await dispatch(fetchAvailableBadges()).unwrap();
|
||||
} catch (error: any) {
|
||||
const message = typeof error === 'string' ? error : error?.message ?? 'Failed to refresh badges';
|
||||
Toast.error(message);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
const gridData = useMemo(() => badges, [badges]);
|
||||
|
||||
const handleBadgePress = useCallback(async (badge: BadgeDto) => {
|
||||
try {
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
} catch {
|
||||
// Best-effort haptics; ignore if unavailable
|
||||
}
|
||||
setShowcaseBadge(badge);
|
||||
}, []);
|
||||
|
||||
const renderBadgeTile = ({ item }: { item: BadgeDto }) => {
|
||||
const isAwarded = item.isAwarded;
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.badgeTile, pressed && styles.badgeTilePressed]}
|
||||
onPress={() => handleBadgePress(item)}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<View style={[styles.badgeImageContainer, isAwarded ? styles.badgeImageEarned : styles.badgeImageLocked]}>
|
||||
{item.imageUrl ? (
|
||||
<Image source={{ uri: item.imageUrl }} style={styles.badgeImage} contentFit="cover" transition={200} />
|
||||
) : (
|
||||
<View style={styles.badgeImageFallback}>
|
||||
<Text style={styles.badgeImageFallbackText}>{item.icon ?? '🏅'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!isAwarded && (
|
||||
<View style={styles.badgeOverlay}>
|
||||
<Ionicons name="lock-closed" size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.badgeTitle} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.badgeDescription} numberOfLines={2}>{item.description}</Text>
|
||||
<Text style={[styles.badgeStatus, isAwarded ? styles.badgeStatusEarned : styles.badgeStatusLocked]}>
|
||||
{isAwarded
|
||||
? t('badges.status.earned')
|
||||
: t('badges.status.locked')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const headerOffset = insets.top + 64;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title={t('badges.title')}
|
||||
/>
|
||||
<FlatList
|
||||
data={gridData}
|
||||
keyExtractor={(item) => item.code}
|
||||
numColumns={3}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{ paddingTop: headerOffset, paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
renderItem={renderBadgeTile}
|
||||
ListHeaderComponent={null}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateTitle}>{t('badges.empty.title')}</Text>
|
||||
<Text style={styles.emptyStateDescription}>{t('badges.empty.description')}</Text>
|
||||
</View>
|
||||
}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing || loading} onRefresh={handleRefresh} tintColor="#7C3AED" />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
<BadgeShowcaseModal
|
||||
badge={showcaseBadge}
|
||||
onClose={() => setShowcaseBadge(null)}
|
||||
username={userProfile?.name && userProfile.name.trim() ? userProfile.name : DEFAULT_MEMBER_NAME}
|
||||
appName="Out Live"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
minHeight: '100%',
|
||||
backgroundColor: '#ffffff'
|
||||
},
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
badgeTile: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#0F172A',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
badgeTilePressed: {
|
||||
transform: [{ scale: 0.97 }],
|
||||
shadowOpacity: 0.02,
|
||||
},
|
||||
badgeImageContainer: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 10,
|
||||
position: 'relative',
|
||||
},
|
||||
badgeImageEarned: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(16,185,129,0.6)',
|
||||
},
|
||||
badgeImageLocked: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(148,163,184,0.5)',
|
||||
},
|
||||
badgeImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
badgeOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(15,23,42,0.45)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeImageFallback: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
badgeImageFallbackText: {
|
||||
fontSize: 36,
|
||||
},
|
||||
badgeTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
badgeDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
badgeStatus: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
marginTop: 6,
|
||||
},
|
||||
badgeStatusEarned: {
|
||||
color: '#0F766E',
|
||||
},
|
||||
badgeStatusLocked: {
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#FFFFFF',
|
||||
marginTop: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
},
|
||||
emptyStateDescription: {
|
||||
fontSize: 14,
|
||||
color: '#475467',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
845
app/basal-metabolism-detail.tsx
Normal file
@@ -0,0 +1,845 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { getLocalizedDateFormat, getMonthDays, 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 { t, i18n } = useI18n();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const userAge = useAppSelector(selectUserAge);
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
// 日期相关状态
|
||||
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 = getMonthDays(undefined, i18n.language as 'zh' | 'en');
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}, [selectedIndex, i18n.language]);
|
||||
|
||||
|
||||
// 计算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 = getMonthDays(undefined, i18n.language as 'zh' | 'en');
|
||||
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 : t('basalMetabolismDetail.chart.error.fetchFailed'));
|
||||
}
|
||||
} 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();
|
||||
const weekNumber = weekOfYear - firstWeekOfYear + 1;
|
||||
return t('basalMetabolismDetail.chart.weekLabel', { week: weekNumber });
|
||||
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={t('basalMetabolismDetail.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,
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 日期选择器 */}
|
||||
<View style={styles.dateContainer}>
|
||||
<DateSelector
|
||||
selectedIndex={selectedIndex}
|
||||
onDateSelect={onSelectDate}
|
||||
showMonthTitle={false}
|
||||
disableFutureDates={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 当前日期基础代谢显示 */}
|
||||
<View style={styles.currentDataCard}>
|
||||
<Text style={styles.currentDataTitle}>
|
||||
{t('basalMetabolismDetail.currentData.title', {
|
||||
date: getLocalizedDateFormat(dayjs(currentSelectedDate), i18n.language as 'zh' | 'en')
|
||||
})}
|
||||
</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 t('basalMetabolismDetail.currentData.noData');
|
||||
})()}
|
||||
</Text>
|
||||
<Text style={styles.currentUnit}>{t('basalMetabolismDetail.currentData.unit')}</Text>
|
||||
</View>
|
||||
{bmrRange && (
|
||||
<Text style={styles.rangeText}>
|
||||
{t('basalMetabolismDetail.currentData.normalRange', {
|
||||
min: bmrRange.min,
|
||||
max: bmrRange.max
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 基础代谢统计 */}
|
||||
<View style={styles.statsCard}>
|
||||
<Text style={styles.statsTitle}>{t('basalMetabolismDetail.stats.title')}</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]}>
|
||||
{t('basalMetabolismDetail.stats.tabs.week')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
|
||||
onPress={() => handleTabPress('month')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||
{t('basalMetabolismDetail.stats.tabs.month')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 柱状图 */}
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingChart}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<Text style={styles.loadingText}>{t('basalMetabolismDetail.chart.loadingText')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorChart}>
|
||||
<Text style={styles.errorText}>
|
||||
{t('basalMetabolismDetail.chart.error.text', { error })}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
// {t('basalMetabolismDetail.comments.reloadData')}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchBasalMetabolismData(activeTab).then(data => {
|
||||
setChartData(data);
|
||||
setIsLoading(false);
|
||||
}).catch(err => {
|
||||
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.retryText}>{t('basalMetabolismDetail.chart.error.retry')}</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={t('basalMetabolismDetail.chart.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}>{t('basalMetabolismDetail.chart.empty')}</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}>{t('basalMetabolismDetail.modal.closeButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.modalTitle}>{t('basalMetabolismDetail.modal.title')}</Text>
|
||||
|
||||
{/* 基础代谢定义 */}
|
||||
<Text style={styles.modalDescription}>
|
||||
{t('basalMetabolismDetail.modal.description')}
|
||||
</Text>
|
||||
|
||||
{/* 为什么重要 */}
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.importance.title')}</Text>
|
||||
<Text style={styles.sectionContent}>
|
||||
{t('basalMetabolismDetail.modal.sections.importance.content')}
|
||||
</Text>
|
||||
|
||||
{/* 正常范围 */}
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.normalRange.title')}</Text>
|
||||
<Text style={styles.formulaText}>
|
||||
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.male')}
|
||||
</Text>
|
||||
<Text style={styles.formulaText}>
|
||||
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.female')}
|
||||
</Text>
|
||||
|
||||
{bmrRange ? (
|
||||
<>
|
||||
<Text style={styles.rangeText}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.userRange', {
|
||||
min: bmrRange.min,
|
||||
max: bmrRange.max
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.rangeNote}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.rangeNote')}
|
||||
</Text>
|
||||
<Text style={styles.userInfoText}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.userInfo', {
|
||||
gender: t(`basalMetabolismDetail.gender.${userProfile.gender === 'male' ? 'male' : 'female'}`),
|
||||
age: userAge,
|
||||
height: userProfile.height,
|
||||
weight: userProfile.weight
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.rangeText}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.incompleteInfo')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 提高代谢率的策略 */}
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.strategies.title')}</Text>
|
||||
<Text style={styles.strategyText}>{t('basalMetabolismDetail.modal.sections.strategies.subtitle')}</Text>
|
||||
|
||||
<View style={styles.strategyList}>
|
||||
{(t('basalMetabolismDetail.modal.sections.strategies.items', { returnObjects: true }) as string[]).map((item: string, index: number) => (
|
||||
<Text key={index} style={styles.strategyItem}>{item}</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
infoButton: {
|
||||
padding: 4,
|
||||
},
|
||||
dateContainer: {
|
||||
marginTop: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
currentDataCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
currentDataTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
currentValueContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: 8,
|
||||
},
|
||||
currentValue: {
|
||||
fontSize: 36,
|
||||
fontWeight: '700',
|
||||
color: '#4ECDC4',
|
||||
},
|
||||
currentUnit: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginLeft: 8,
|
||||
},
|
||||
rangeText: {
|
||||
fontSize: 14,
|
||||
color: '#059669',
|
||||
textAlign: 'center',
|
||||
},
|
||||
statsCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
statsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F5F5F7',
|
||||
borderRadius: 12,
|
||||
padding: 4,
|
||||
marginBottom: 20,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#888',
|
||||
},
|
||||
activeTabText: {
|
||||
color: '#192126',
|
||||
},
|
||||
chart: {
|
||||
marginVertical: 8,
|
||||
borderRadius: 16,
|
||||
},
|
||||
emptyChart: {
|
||||
height: 220,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 16,
|
||||
marginVertical: 8,
|
||||
},
|
||||
emptyChartText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
fontWeight: '500',
|
||||
},
|
||||
loadingChart: {
|
||||
height: 220,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 16,
|
||||
marginVertical: 8,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 8,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorChart: {
|
||||
height: 220,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#FFF5F5',
|
||||
borderRadius: 16,
|
||||
marginVertical: 8,
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
color: '#E53E3E',
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#4ECDC4',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: 24,
|
||||
maxHeight: '90%',
|
||||
width: '100%',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -5,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
closeButtonText: {
|
||||
fontSize: 20,
|
||||
color: '#64748B',
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalDescription: {
|
||||
fontSize: 15,
|
||||
color: '#475569',
|
||||
lineHeight: 22,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
sectionContent: {
|
||||
fontSize: 15,
|
||||
color: '#475569',
|
||||
lineHeight: 22,
|
||||
marginBottom: 20,
|
||||
},
|
||||
formulaText: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 4,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
rangeNote: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
userInfoText: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
strategyText: {
|
||||
fontSize: 15,
|
||||
color: '#475569',
|
||||
marginBottom: 12,
|
||||
},
|
||||
strategyList: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
strategyItem: {
|
||||
fontSize: 14,
|
||||
color: '#64748B',
|
||||
lineHeight: 20,
|
||||
marginBottom: 8,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import React from 'react';
|
||||
|
||||
export default function ChallengeLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="day" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { completeDay, setCustom } from '@/store/challengeSlice';
|
||||
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export default function ChallengeDayScreen() {
|
||||
const { day } = useLocalSearchParams<{ day: string }>();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||
const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10)));
|
||||
const dayState = challenge?.days?.[dayNumber - 1];
|
||||
const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState<Record<string, number>>({});
|
||||
const [custom, setCustomLocal] = useState<ExerciseCustomConfig[]>(dayState?.custom || []);
|
||||
|
||||
const isLocked = dayState?.status === 'locked';
|
||||
const isCompleted = dayState?.status === 'completed';
|
||||
const plan = dayState?.plan;
|
||||
|
||||
// 不再强制所有动作完成,始终允许完成
|
||||
const canFinish = true;
|
||||
|
||||
const handleNextSet = (ex: Exercise) => {
|
||||
const curr = currentSetIndexByExercise[ex.key] ?? 0;
|
||||
if (curr < ex.sets.length) {
|
||||
setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
// 持久化自定义配置
|
||||
await dispatch(setCustom({ dayNumber, custom: custom }));
|
||||
await dispatch(completeDay(dayNumber));
|
||||
router.back();
|
||||
};
|
||||
|
||||
const updateCustom = (key: string, partial: Partial<ExerciseCustomConfig>) => {
|
||||
setCustomLocal((prev) => {
|
||||
const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}><Text>加载中...</Text></View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title={`第${plan.dayNumber}天`} onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<Text style={styles.title}>{plan.title}</Text>
|
||||
<Text style={styles.subtitle}>{plan.focus}</Text>
|
||||
|
||||
<FlatList
|
||||
data={plan.exercises}
|
||||
keyExtractor={(item) => item.key}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }}
|
||||
renderItem={({ item }) => {
|
||||
const doneSets = currentSetIndexByExercise[item.key] ?? 0;
|
||||
const conf = custom.find((c) => c.key === item.key);
|
||||
const targetSets = conf?.sets ?? item.sets.length;
|
||||
const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40;
|
||||
return (
|
||||
<View style={styles.exerciseCard}>
|
||||
<View style={styles.exerciseHeader}>
|
||||
<Text style={styles.exerciseName}>{item.name}</Text>
|
||||
<Text style={styles.exerciseDesc}>{item.description}</Text>
|
||||
</View>
|
||||
<View style={styles.controlsRow}>
|
||||
<TouchableOpacity style={[styles.toggleBtn, conf?.enabled === false && styles.toggleBtnOff]} onPress={() => updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
|
||||
<Text style={styles.toggleBtnText}>{conf?.enabled === false ? '已关闭' : '已启用'}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.counterBox}>
|
||||
<Text style={styles.counterLabel}>组数</Text>
|
||||
<View style={styles.counterRow}>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
||||
<Text style={styles.counterValue}>{conf?.sets ?? targetSets}</Text>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.counterBox}>
|
||||
<Text style={styles.counterLabel}>时长/组</Text>
|
||||
<View style={styles.counterRow}>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
||||
<Text style={styles.counterValue}>{conf?.durationSec ?? perSetDuration}s</Text>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.setsRow}>
|
||||
{Array.from({ length: targetSets }).map((_, idx) => (
|
||||
<View key={idx} style={[styles.setPill, idx < doneSets ? styles.setPillDone : styles.setPillTodo]}>
|
||||
<Text style={[styles.setPillText, idx < doneSets ? styles.setPillTextDone : styles.setPillTextTodo]}>
|
||||
{perSetDuration}s
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity style={styles.nextSetBtn} onPress={() => handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
|
||||
<Text style={styles.nextSetText}>{doneSets >= item.sets.length ? '本动作完成' : '完成一组'}</Text>
|
||||
</TouchableOpacity>
|
||||
{item.tips && (
|
||||
<View style={styles.tipsBox}>
|
||||
{item.tips.map((t: string, i: number) => (
|
||||
<Text key={i} style={styles.tipText}>• {t}</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity style={[styles.finishBtn, !canFinish && { opacity: 0.5 }]} disabled={!canFinish || isLocked || isCompleted} onPress={handleComplete}>
|
||||
<Text style={styles.finishBtnText}>{isCompleted ? '已完成' : '完成今日训练'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
||||
title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' },
|
||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
exerciseCard: {
|
||||
backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12,
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
},
|
||||
exerciseHeader: { marginBottom: 8 },
|
||||
exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||||
exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||
setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
|
||||
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 },
|
||||
toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
||||
toggleBtnOff: { backgroundColor: '#9CA3AF' },
|
||||
toggleBtnText: { color: '#FFFFFF', fontWeight: '700' },
|
||||
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
|
||||
counterLabel: { fontSize: 10, color: '#6B7280' },
|
||||
counterRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
|
||||
counterBtnText: { fontWeight: '800', color: '#111827' },
|
||||
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
|
||||
setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 },
|
||||
setPillTodo: { backgroundColor: '#F3F4F6' },
|
||||
setPillDone: { backgroundColor: '#BBF246' },
|
||||
setPillText: { fontSize: 12, fontWeight: '700' },
|
||||
setPillTextTodo: { color: '#6B7280' },
|
||||
setPillTextDone: { color: '#192126' },
|
||||
nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
||||
nextSetText: { color: '#FFFFFF', fontWeight: '700' },
|
||||
tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 },
|
||||
tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 },
|
||||
bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' },
|
||||
finishBtn: { backgroundColor: '#BBF246', paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
||||
finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { initChallenge } from '@/store/challengeSlice';
|
||||
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Dimensions, FlatList, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export default function ChallengeHomeScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(initChallenge());
|
||||
}, [dispatch]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const total = challenge?.days?.length || 30;
|
||||
const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0;
|
||||
return total ? done / total : 0;
|
||||
}, [challenge?.days]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
||||
|
||||
{/* 进度环与统计 */}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryLeft}>
|
||||
<View style={styles.progressPill}>
|
||||
<View style={[styles.progressFill, { width: `${Math.round((progress || 0) * 100)}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>{Math.round((progress || 0) * 100)}%</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRight}>
|
||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{challenge?.streak ?? 0}</Text> 天连续</Text>
|
||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 日历格子(简单 6x5 网格) */}
|
||||
<FlatList
|
||||
data={challenge?.days || []}
|
||||
keyExtractor={(item) => String(item.plan.dayNumber)}
|
||||
numColumns={5}
|
||||
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }}
|
||||
renderItem={({ item }) => {
|
||||
const { plan, status } = item;
|
||||
const isLocked = status === 'locked';
|
||||
const isCompleted = status === 'completed';
|
||||
const minutes = estimateSessionMinutesWithCustom(plan, item.custom);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isLocked}
|
||||
onPress={async () => {
|
||||
if (!(await ensureLoggedIn({ redirectTo: '/challenge', redirectParams: {} }))) return;
|
||||
router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } });
|
||||
}}
|
||||
style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.dayNumber, isLocked && styles.dayNumberLocked]}>{plan.dayNumber}</Text>
|
||||
<Text style={styles.dayMinutes}>{minutes}′</Text>
|
||||
{isCompleted && <Ionicons name="checkmark-circle" size={18} color="#10B981" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
||||
{isLocked && <Ionicons name="lock-closed" size={16} color="#9CA3AF" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部 CTA */}
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity style={styles.startButton} onPress={async () => {
|
||||
if (!(await ensureLoggedIn({ redirectTo: '/challenge' }))) return;
|
||||
router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d: any) => d.status === 'available')?.plan.dayNumber) || 1) } });
|
||||
}}>
|
||||
<Text style={styles.startButtonText}>开始今日训练</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
header: { paddingHorizontal: 20, paddingTop: 10 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||
headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' },
|
||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
summaryCard: {
|
||||
marginTop: 16,
|
||||
marginHorizontal: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
},
|
||||
summaryLeft: { flexDirection: 'row', alignItems: 'center' },
|
||||
progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' },
|
||||
progressFill: { height: '100%', backgroundColor: '#BBF246' },
|
||||
progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' },
|
||||
summaryRight: {},
|
||||
summaryItem: { fontSize: 12, color: '#6B7280' },
|
||||
summaryItemValue: { fontWeight: '800', color: '#111827' },
|
||||
dayCell: {
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
},
|
||||
dayCellLocked: { backgroundColor: '#F3F4F6' },
|
||||
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
|
||||
dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 },
|
||||
dayNumberLocked: { color: '#9CA3AF' },
|
||||
dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||
bottomBar: { padding: 20 },
|
||||
startButton: { backgroundColor: '#BBF246', paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
||||
startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
||||
});
|
||||
|
||||
|
||||
1536
app/challenges/[id]/index.tsx
Normal file
305
app/challenges/[id]/leaderboard.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
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 { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
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 safeAreaTop = useSafeAreaTop()
|
||||
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 { t } = useI18n();
|
||||
|
||||
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={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.notFound')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (detailStatus === 'loading' && !challenge) {
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</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={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||
{detailError ?? t('challengeDetail.leaderboard.loadFailed')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const rankingData = rankingList?.items ?? challenge.rankings ?? [];
|
||||
const subtitle = challenge.rankingDescription ?? challenge.summary;
|
||||
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
|
||||
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 }]}>{t('challengeDetail.leaderboard.loading')}</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}>{t('challengeDetail.leaderboard.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoadingMore ? (
|
||||
<View style={styles.loadMoreIndicator}>
|
||||
<ActivityIndicator color={colorTokens.primary} size="small" />
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>{t('challengeDetail.leaderboard.loadMore')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{rankingLoadMoreStatus === 'failed' ? (
|
||||
<View style={styles.loadMoreIndicator}>
|
||||
<Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</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',
|
||||
},
|
||||
});
|
||||
1083
app/challenges/create-custom.tsx
Normal file
705
app/circumference-detail.tsx
Normal file
@@ -0,0 +1,705 @@
|
||||
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 { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
|
||||
|
||||
type TabType = CircumferencePeriod;
|
||||
|
||||
export default function CircumferenceDetailScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 日期相关状态
|
||||
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: t('circumferenceDetail.measurements.chest'),
|
||||
value: userProfile?.chestCircumference,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
{
|
||||
key: 'waistCircumference',
|
||||
label: t('circumferenceDetail.measurements.waist'),
|
||||
value: userProfile?.waistCircumference,
|
||||
color: '#4ECDC4',
|
||||
},
|
||||
{
|
||||
key: 'upperHipCircumference',
|
||||
label: t('circumferenceDetail.measurements.upperHip'),
|
||||
value: userProfile?.upperHipCircumference,
|
||||
color: '#45B7D1',
|
||||
},
|
||||
{
|
||||
key: 'armCircumference',
|
||||
label: t('circumferenceDetail.measurements.arm'),
|
||||
value: userProfile?.armCircumference,
|
||||
color: '#96CEB4',
|
||||
},
|
||||
{
|
||||
key: 'thighCircumference',
|
||||
label: t('circumferenceDetail.measurements.thigh'),
|
||||
value: userProfile?.thighCircumference,
|
||||
color: '#FFEAA7',
|
||||
},
|
||||
{
|
||||
key: 'calfCircumference',
|
||||
label: t('circumferenceDetail.measurements.calf'),
|
||||
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 t('circumferenceDetail.chart.weekLabel', { week: weekOfYear - firstWeekOfMonth + 1 });
|
||||
case 'year':
|
||||
// 将YYYY-MM格式转换为月份
|
||||
return t('circumferenceDetail.chart.monthLabel', { month: 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={t('circumferenceDetail.title')}
|
||||
transparent
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
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}>{t('circumferenceDetail.title')}</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]}>
|
||||
{t('circumferenceDetail.tabs.week')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'month' && styles.activeTab]}
|
||||
onPress={() => handleTabPress('month')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||
{t('circumferenceDetail.tabs.month')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, activeTab === 'year' && styles.activeTab]}
|
||||
onPress={() => handleTabPress('year')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
|
||||
{t('circumferenceDetail.tabs.year')}
|
||||
</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
|
||||
]}>
|
||||
{t(`circumferenceDetail.measurements.${type.key.replace('Circumference', '')}`)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 折线图 */}
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingChart}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<Text style={styles.loadingText}>{t('circumferenceDetail.loading')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorChart}>
|
||||
<Text style={styles.errorText}>{t('circumferenceDetail.error')}: {error}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.retryText}>{t('circumferenceDetail.retry')}</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
|
||||
? t('circumferenceDetail.chart.empty')
|
||||
: t('circumferenceDetail.chart.noSelection')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 围度编辑弹窗 */}
|
||||
<FloatingSelectionModal
|
||||
visible={modalVisible}
|
||||
onClose={() => {
|
||||
setModalVisible(false);
|
||||
setSelectedMeasurement(null);
|
||||
}}
|
||||
title={selectedMeasurement ? t('circumferenceDetail.modal.title', { label: selectedMeasurement.label }) : t('circumferenceDetail.modal.defaultTitle')}
|
||||
items={circumferenceOptions}
|
||||
selectedValue={selectedMeasurement?.currentValue}
|
||||
onValueChange={() => { }} // Real-time update not needed
|
||||
onConfirm={handleUpdateMeasurement}
|
||||
confirmButtonText={t('circumferenceDetail.modal.confirm')}
|
||||
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
166
app/developer.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function DeveloperScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const resetOnboardingStatus = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.onboardingCompleted);
|
||||
Alert.alert('成功', '引导状态已重置,下次启动应用将重新显示引导页面');
|
||||
} catch (error) {
|
||||
console.error('重置引导状态失败:', error);
|
||||
Alert.alert('错误', '重置引导状态失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const developerItems = [
|
||||
{
|
||||
title: '日志',
|
||||
subtitle: '查看应用运行日志',
|
||||
icon: 'document-text-outline',
|
||||
onPress: () => router.push(ROUTES.DEVELOPER_LOGS),
|
||||
},
|
||||
{
|
||||
title: '重置引导状态',
|
||||
subtitle: '清除 onboarding 缓存,下次启动将重新显示引导页面',
|
||||
icon: 'refresh-outline',
|
||||
onPress: resetOnboardingStatus,
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
328
app/developer/logs.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { FastingNotificationTestPanel } from '@/components/developer/FastingNotificationTestPanel';
|
||||
import { log, LogEntry, logger } 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 [showTestPanel, setShowTestPanel] = 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('错误', '导出日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotifications = () => {
|
||||
setShowTestPanel(true);
|
||||
};
|
||||
|
||||
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={handleTestNotifications} style={styles.actionButton}>
|
||||
<Ionicons name="notifications-outline" size={20} color="#FF8800" />
|
||||
</TouchableOpacity>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 断食通知测试面板 */}
|
||||
{showTestPanel && (
|
||||
<FastingNotificationTestPanel
|
||||
onClose={() => setShowTestPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</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',
|
||||
},
|
||||
});
|
||||
@@ -6,16 +6,16 @@ import { ThemedView } from '@/components/ThemedView';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { listRecommendedArticles } from '@/services/articles';
|
||||
import { fetchRecommendations, RecommendationType } from '@/services/recommendations';
|
||||
import { loadPlans, type TrainingPlan } from '@/store/trainingPlanSlice';
|
||||
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 { getChineseGreeting } from '@/utils/date';
|
||||
import { TrainingPlan } from '@/services/trainingPlanApi';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Animated, Image, PanResponder, Pressable, SafeAreaView, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Animated, Image, PanResponder, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// 移除旧的“热门活动”滑动数据,改为固定的“热点功能”卡片
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function HomeScreen() {
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
|
||||
// 训练计划状态
|
||||
const { plans, currentId } = useAppSelector((s) => s.trainingPlan);
|
||||
const { plans } = useAppSelector((s) => s.trainingPlan);
|
||||
const [activePlan, setActivePlan] = React.useState<TrainingPlan | null>(null);
|
||||
|
||||
// Draggable coach badge state
|
||||
@@ -76,7 +76,7 @@ export default function HomeScreen() {
|
||||
Animated.spring(pan, { toValue: { x: snapX, y: clampedY }, useNativeDriver: false, bounciness: 6 }).start(() => {
|
||||
if (!dragState.current.moved) {
|
||||
// 切换到教练 tab,并传递name参数
|
||||
router.push('/coach?name=Iris' as any);
|
||||
router.push(`${ROUTES.TAB_COACH}?${QUERY_PARAMS.COACH_NAME}=Iris` as any);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -102,42 +102,7 @@ export default function HomeScreen() {
|
||||
readCount: number;
|
||||
};
|
||||
|
||||
// 打底数据(接口不可用时)
|
||||
const getFallbackItems = React.useCallback((): RecommendItem[] => {
|
||||
return [
|
||||
{
|
||||
type: 'plan',
|
||||
key: 'today-workout',
|
||||
image:
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||
title: '今日训练',
|
||||
subtitle: '完成一次普拉提训练,记录你的坚持',
|
||||
level: '初学者',
|
||||
onPress: () => pushIfAuthedElseLogin('/workout/today'),
|
||||
},
|
||||
{
|
||||
type: 'plan',
|
||||
key: 'assess',
|
||||
image:
|
||||
'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||
title: '体态评估',
|
||||
subtitle: '评估你的体态,制定训练计划',
|
||||
level: '初学者',
|
||||
onPress: () => router.push('/ai-posture-assessment'),
|
||||
},
|
||||
...listRecommendedArticles().map((a) => ({
|
||||
type: 'article' as const,
|
||||
key: `article-${a.id}`,
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
coverImage: a.coverImage,
|
||||
publishedAt: a.publishedAt,
|
||||
readCount: a.readCount,
|
||||
})),
|
||||
];
|
||||
}, [router, pushIfAuthedElseLogin]);
|
||||
|
||||
const [items, setItems] = React.useState<RecommendItem[]>(() => getFallbackItems());
|
||||
const [items, setItems] = React.useState<RecommendItem[]>();
|
||||
|
||||
// 加载训练计划数据
|
||||
React.useEffect(() => {
|
||||
@@ -148,13 +113,13 @@ export default function HomeScreen() {
|
||||
|
||||
// 获取激活的训练计划
|
||||
React.useEffect(() => {
|
||||
if (isLoggedIn && currentId && plans.length > 0) {
|
||||
const currentPlan = plans.find(p => p.id === currentId);
|
||||
if (isLoggedIn && plans.length > 0) {
|
||||
const currentPlan = plans.find(p => p.isActive);
|
||||
setActivePlan(currentPlan || null);
|
||||
} else {
|
||||
setActivePlan(null);
|
||||
}
|
||||
}, [isLoggedIn, currentId, plans]);
|
||||
}, [isLoggedIn, plans]);
|
||||
|
||||
// 拉取推荐接口(已登录时)
|
||||
React.useEffect(() => {
|
||||
@@ -185,26 +150,26 @@ export default function HomeScreen() {
|
||||
image: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg',
|
||||
title: c.title || '今日训练',
|
||||
subtitle: c.subtitle || '完成一次普拉提训练,记录你的坚持',
|
||||
onPress: () => pushIfAuthedElseLogin('/workout/today'),
|
||||
onPress: () => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 若接口返回空,也回退到打底
|
||||
setItems(mapped.length > 0 ? mapped : getFallbackItems());
|
||||
setItems(mapped.length > 0 ? mapped : []);
|
||||
} catch (e) {
|
||||
console.error('fetchRecommendations error', e);
|
||||
setItems(getFallbackItems());
|
||||
setItems([]);
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { canceled = true; };
|
||||
}, [isLoggedIn, pushIfAuthedElseLogin, getFallbackItems]);
|
||||
}, [isLoggedIn, pushIfAuthedElseLogin]);
|
||||
|
||||
// 处理点击训练计划卡片,跳转到锻炼tab
|
||||
const handlePlanCardPress = () => {
|
||||
if (activePlan) {
|
||||
// 跳转到训练计划页面的锻炼tab,并传递planId参数
|
||||
router.push(`/training-plan?planId=${activePlan.id}&tab=schedule` as any);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,10 +224,10 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Header Section */}
|
||||
<View style={styles.header}>
|
||||
{/* <View style={styles.header}>
|
||||
<ThemedText style={styles.greeting}>{getChineseGreeting()}</ThemedText>
|
||||
<ThemedText style={styles.userName}>新学员,欢迎你</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.userName}></ThemedText>
|
||||
</View> */}
|
||||
|
||||
{/* Search Box */}
|
||||
<SearchBox placeholder="搜索" />
|
||||
@@ -275,7 +240,7 @@ export default function HomeScreen() {
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardQuinary]}
|
||||
onPress={() => pushIfAuthedElseLogin('/workout/today')}
|
||||
onPress={() => pushIfAuthedElseLogin(ROUTES.WORKOUT_TODAY)}
|
||||
>
|
||||
<View style={styles.featureIconWrapper}>
|
||||
<View style={styles.featureIconPlaceholder}>
|
||||
@@ -290,7 +255,7 @@ export default function HomeScreen() {
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardPrimary]}
|
||||
onPress={() => pushIfAuthedElseLogin('/ai-posture-assessment')}
|
||||
onPress={() => pushIfAuthedElseLogin(ROUTES.AI_POSTURE_ASSESSMENT)}
|
||||
>
|
||||
<View style={styles.featureIconWrapper}>
|
||||
<Image
|
||||
@@ -303,7 +268,7 @@ export default function HomeScreen() {
|
||||
|
||||
<Pressable
|
||||
style={[styles.featureCard, styles.featureCardQuaternary]}
|
||||
onPress={() => pushIfAuthedElseLogin('/training-plan')}
|
||||
onPress={() => pushIfAuthedElseLogin(ROUTES.TRAINING_PLAN)}
|
||||
>
|
||||
<View style={styles.featureIconWrapper}>
|
||||
<View style={styles.featureIconPlaceholder}>
|
||||
@@ -334,7 +299,7 @@ export default function HomeScreen() {
|
||||
<ThemedText style={styles.sectionTitle}>为你推荐</ThemedText>
|
||||
|
||||
<View style={styles.planList}>
|
||||
{items.map((item) => {
|
||||
{items?.map((item) => {
|
||||
if (item.type === 'article') {
|
||||
return (
|
||||
<ArticleCard
|
||||
528
app/fasting/[planId].tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
import { CircularRing } from '@/components/CircularRing';
|
||||
import { FastingStartPickerModal } from '@/components/fasting/FastingStartPickerModal';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { FASTING_PLANS, FastingPlan, getPlanById, getRecommendedStart } from '@/constants/Fasting';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import {
|
||||
rescheduleActivePlan,
|
||||
scheduleFastingPlan,
|
||||
selectActiveFastingSchedule,
|
||||
} from '@/store/fastingSlice';
|
||||
import { buildDisplayWindow, calculateFastingWindow, savePreferredPlanId } from '@/utils/fasting';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type InfoTab = 'fit' | 'avoid' | 'intro';
|
||||
|
||||
const TAB_LABELS: Record<InfoTab, string> = {
|
||||
fit: '适合人群',
|
||||
avoid: '不适合人群',
|
||||
intro: '计划介绍',
|
||||
};
|
||||
|
||||
export default function FastingPlanDetailScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colors = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const activeSchedule = useAppSelector(selectActiveFastingSchedule);
|
||||
|
||||
const { planId } = useLocalSearchParams<{ planId: string }>();
|
||||
const fallbackPlan = FASTING_PLANS[0];
|
||||
const plan: FastingPlan = useMemo(
|
||||
() => (planId ? getPlanById(planId) ?? fallbackPlan : fallbackPlan),
|
||||
[planId, fallbackPlan]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void savePreferredPlanId(plan.id);
|
||||
}, [plan.id]);
|
||||
|
||||
const [infoTab, setInfoTab] = useState<InfoTab>('fit');
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
const recommendedStart = useMemo(() => getRecommendedStart(plan), [plan]);
|
||||
const window = calculateFastingWindow(recommendedStart, plan.fastingHours);
|
||||
const displayWindow = buildDisplayWindow(window.start, window.end);
|
||||
|
||||
const handleStartWithRecommended = () => {
|
||||
dispatch(scheduleFastingPlan({ planId: plan.id, start: recommendedStart.toISOString(), origin: 'recommended' }));
|
||||
router.replace(ROUTES.TAB_FASTING);
|
||||
};
|
||||
|
||||
const handleOpenPicker = () => {
|
||||
setShowPicker(true);
|
||||
};
|
||||
|
||||
const handleConfirmPicker = (date: Date) => {
|
||||
if (activeSchedule?.planId === plan.id) {
|
||||
dispatch(rescheduleActivePlan({ start: date.toISOString(), origin: 'manual' }));
|
||||
} else {
|
||||
dispatch(scheduleFastingPlan({ planId: plan.id, start: date.toISOString(), origin: 'manual' }));
|
||||
}
|
||||
setShowPicker(false);
|
||||
router.replace(ROUTES.TAB_FASTING);
|
||||
};
|
||||
|
||||
const renderInfoList = () => {
|
||||
let items: string[] = [];
|
||||
if (infoTab === 'fit') items = plan.audienceFit;
|
||||
if (infoTab === 'avoid') items = plan.audienceAvoid;
|
||||
if (infoTab === 'intro') items = [plan.description, ...plan.nutritionTips];
|
||||
|
||||
return (
|
||||
<View style={styles.infoList}>
|
||||
{items.map((item) => (
|
||||
<View key={item} style={styles.infoItem}>
|
||||
<View style={[styles.infoDot, { backgroundColor: plan.theme.accent }]} />
|
||||
<Text style={styles.infoText}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const fastingRatio = plan.fastingHours / 24;
|
||||
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#ffffff' }]}>
|
||||
{/* 固定悬浮的返回按钮 */}
|
||||
<View style={[styles.backButtonContainer, { paddingTop: insets.top + 12 }]}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={router.back} activeOpacity={0.8}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.backButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255,255,255,0.4)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#2E3142" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.backButtonFallback}>
|
||||
<Ionicons name="chevron-back" size={24} color="#2E3142" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
||||
<LinearGradient
|
||||
colors={[plan.theme.accentSecondary, plan.theme.backdrop]}
|
||||
style={[styles.hero, { paddingTop: insets.top + 12 }]}
|
||||
>
|
||||
<View style={{
|
||||
paddingTop: insets.top + 12
|
||||
}}>
|
||||
<View style={styles.heroHeader}>
|
||||
<Text style={styles.planId}>{plan.id}</Text>
|
||||
{plan.badge && (
|
||||
<View style={[styles.heroBadge, { backgroundColor: `${plan.theme.accent}2B` }]}>
|
||||
<Text style={[styles.heroBadgeText, { color: plan.theme.accent }]}>{plan.badge}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.heroTitle}>{plan.title}</Text>
|
||||
<Text style={styles.heroSubtitle}>{plan.subtitle}</Text>
|
||||
|
||||
<View style={styles.tagRow}>
|
||||
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
|
||||
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
|
||||
断食 {plan.fastingHours} 小时
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.tagChip, { backgroundColor: `${plan.theme.accent}22` }]}>
|
||||
<Text style={[styles.tagChipText, { color: plan.theme.accent }]}>
|
||||
进食 {plan.eatingHours} 小时
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
<View style={styles.body}>
|
||||
<View style={styles.chartCard}>
|
||||
<CircularRing
|
||||
size={190}
|
||||
strokeWidth={18}
|
||||
progress={fastingRatio}
|
||||
progressColor={plan.theme.accent}
|
||||
trackColor={plan.theme.ringTrack}
|
||||
showCenterText={false}
|
||||
/>
|
||||
<View style={styles.chartContent}>
|
||||
<Text style={styles.chartTitle}>每日节奏</Text>
|
||||
<Text style={styles.chartValue}>{plan.fastingHours} h 断食</Text>
|
||||
<Text style={styles.chartSubtitle}>进食窗口 {plan.eatingHours} h</Text>
|
||||
</View>
|
||||
<View style={styles.legendRow}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: plan.theme.accent }]} />
|
||||
<Text style={styles.legendText}>断食期</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: plan.theme.ringTrack }]} />
|
||||
<Text style={styles.legendText}>进食期</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.windowCard}>
|
||||
<Text style={styles.windowLabel}>推荐开始时间</Text>
|
||||
<View style={styles.windowRow}>
|
||||
<View style={styles.windowCell}>
|
||||
<Text style={styles.windowTitle}>开始</Text>
|
||||
<Text style={styles.windowDay}>{displayWindow.startDayLabel}</Text>
|
||||
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
|
||||
{displayWindow.startTimeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.windowDivider} />
|
||||
<View style={styles.windowCell}>
|
||||
<Text style={styles.windowTitle}>结束</Text>
|
||||
<Text style={styles.windowDay}>{displayWindow.endDayLabel}</Text>
|
||||
<Text style={[styles.windowTime, { color: plan.theme.accent }]}>
|
||||
{displayWindow.endTimeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.windowHint}>
|
||||
推荐在晚餐后约 2 小时开始,保证进食期覆盖早餐至午后。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.tabContainer}>
|
||||
{(Object.keys(TAB_LABELS) as InfoTab[]).map((tabKey) => {
|
||||
const isActive = infoTab === tabKey;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tabKey}
|
||||
style={[
|
||||
styles.tabButton,
|
||||
isActive && { backgroundColor: plan.theme.accent },
|
||||
]}
|
||||
onPress={() => setInfoTab(tabKey)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabButtonText,
|
||||
{ color: isActive ? '#fff' : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{TAB_LABELS[tabKey]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{renderInfoList()}
|
||||
|
||||
<View style={styles.actionBlock}>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryAction, { borderColor: plan.theme.accent }]}
|
||||
onPress={handleOpenPicker}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={[styles.secondaryActionText, { color: plan.theme.accent }]}>
|
||||
自定义开始时间
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryAction, { backgroundColor: plan.theme.accent }]}
|
||||
onPress={handleStartWithRecommended}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Text style={styles.primaryActionText}>
|
||||
开始轻断食
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<FastingStartPickerModal
|
||||
visible={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
initialDate={recommendedStart}
|
||||
recommendedDate={recommendedStart}
|
||||
onConfirm={handleConfirmPicker}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
hero: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 32,
|
||||
borderBottomLeftRadius: 32,
|
||||
borderBottomRightRadius: 32,
|
||||
},
|
||||
backButtonContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 24,
|
||||
zIndex: 10,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
backButtonGlass: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
backButtonFallback: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
heroContent: {
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
planId: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
marginRight: 12,
|
||||
},
|
||||
heroBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
heroBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
color: '#2E3142',
|
||||
marginBottom: 8,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#5B6572',
|
||||
marginBottom: 16,
|
||||
},
|
||||
tagRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tagChip: {
|
||||
marginRight: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
},
|
||||
tagChipText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 28,
|
||||
},
|
||||
chartCard: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
chartContent: {
|
||||
position: 'absolute',
|
||||
top: 70,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chartTitle: {
|
||||
fontSize: 14,
|
||||
color: '#6F7D87',
|
||||
marginBottom: 6,
|
||||
},
|
||||
chartValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
},
|
||||
chartSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#6F7D87',
|
||||
marginTop: 4,
|
||||
},
|
||||
legendRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: 20,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 12,
|
||||
},
|
||||
legendDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 6,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 12,
|
||||
color: '#5B6572',
|
||||
},
|
||||
windowCard: {
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 20,
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 3,
|
||||
},
|
||||
windowLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
marginBottom: 12,
|
||||
},
|
||||
windowRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
windowCell: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
windowTitle: {
|
||||
fontSize: 12,
|
||||
color: '#778290',
|
||||
marginBottom: 6,
|
||||
},
|
||||
windowDay: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#2E3142',
|
||||
},
|
||||
windowTime: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginTop: 6,
|
||||
},
|
||||
windowDivider: {
|
||||
width: 1,
|
||||
height: 60,
|
||||
backgroundColor: 'rgba(95,105,116,0.2)',
|
||||
},
|
||||
windowHint: {
|
||||
fontSize: 12,
|
||||
color: '#6F7D87',
|
||||
marginTop: 16,
|
||||
lineHeight: 18,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F2F3F5',
|
||||
padding: 4,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
tabButtonText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoList: {
|
||||
marginBottom: 28,
|
||||
},
|
||||
infoItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
marginRight: 10,
|
||||
marginTop: 7,
|
||||
},
|
||||
infoText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#4A5460',
|
||||
lineHeight: 21,
|
||||
},
|
||||
actionBlock: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 50,
|
||||
},
|
||||
secondaryAction: {
|
||||
flex: 1,
|
||||
borderWidth: 1.4,
|
||||
borderRadius: 24,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
secondaryActionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryAction: {
|
||||
flex: 1,
|
||||
borderRadius: 24,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryActionText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
300
app/fasting/references.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// 参考文献数据
|
||||
const references = [
|
||||
{
|
||||
id: 5,
|
||||
name: '中国国家卫生健康委员会(国家卫健委)',
|
||||
englishName: 'National Health Commission of the People\'s Republic of China',
|
||||
url: 'http://www.nhc.gov.cn',
|
||||
note: '(用于中文用户环境非常合适)',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: '美国国立卫生研究院(NIH)',
|
||||
englishName: 'National Institutes of Health',
|
||||
url: 'https://www.nih.gov',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '世界卫生组织(WHO)',
|
||||
englishName: 'World Health Organization',
|
||||
url: 'https://www.who.int',
|
||||
},
|
||||
|
||||
{
|
||||
id: 6,
|
||||
name: '中国营养学会(Chinese Nutrition Society)',
|
||||
englishName: 'Chinese Nutrition Society',
|
||||
url: 'https://www.cnsoc.org',
|
||||
},
|
||||
];
|
||||
|
||||
export default function FastingReferencesScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colors = Colors[theme];
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleLinkPress = async (url: string) => {
|
||||
try {
|
||||
const canOpen = await Linking.canOpenURL(url);
|
||||
if (canOpen) {
|
||||
await Linking.openURL(url);
|
||||
} else {
|
||||
console.log('无法打开链接:', url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('打开链接时发生错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#ffffff' }]}>
|
||||
{/* 固定悬浮的返回按钮 */}
|
||||
<View style={[styles.backButtonContainer, { paddingTop: insets.top + 12 }]}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={handleBack} activeOpacity={0.8}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.backButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255,255,255,0.4)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#2E3142" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.backButtonFallback}>
|
||||
<Ionicons name="chevron-back" size={24} color="#2E3142" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContainer,
|
||||
{ paddingTop: insets.top + 80 }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.headerSection}>
|
||||
<Text style={styles.title}>参考文献与医学来源</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
本应用的断食相关功能和建议基于以下权威医学机构的科学研究和指导原则
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.referencesList}>
|
||||
{references.map((reference) => (
|
||||
<View key={reference.id} style={styles.referenceCard}>
|
||||
<View style={styles.referenceHeader}>
|
||||
<View style={styles.referenceIcon}>
|
||||
<Ionicons name="medical-outline" size={24} color="#2E3142" />
|
||||
</View>
|
||||
<View style={styles.referenceInfo}>
|
||||
<Text style={styles.referenceName}>{reference.name}</Text>
|
||||
<Text style={styles.referenceEnglishName}>{reference.englishName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.referenceLink}
|
||||
onPress={() => handleLinkPress(reference.url)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.referenceUrl}>{reference.url}</Text>
|
||||
<Ionicons name="open-outline" size={16} color="#6F7D87" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{reference.note && (
|
||||
<Text style={styles.referenceNote}>{reference.note}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.disclaimerSection}>
|
||||
<View style={styles.disclaimerHeader}>
|
||||
<Ionicons name="information-circle-outline" size={20} color="#6F7D87" />
|
||||
<Text style={styles.disclaimerTitle}>重要声明</Text>
|
||||
</View>
|
||||
<Text style={styles.disclaimerText}>
|
||||
本应用提供的断食相关信息仅供参考,不能替代专业医疗建议。在开始任何断食计划前,
|
||||
请咨询医生或专业医疗人员的意见,特别是如果您有基础疾病、正在服药或处于特殊生理时期(如怀孕、哺乳期等)。
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
backButtonContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 24,
|
||||
zIndex: 10,
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
backButtonGlass: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
backButtonFallback: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
scrollContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
headerSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#2E3142',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#6F7D87',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
referencesList: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
referenceCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
},
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 16,
|
||||
elevation: 4,
|
||||
},
|
||||
referenceHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
referenceIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(46, 49, 66, 0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
referenceInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
referenceName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
marginBottom: 4,
|
||||
},
|
||||
referenceEnglishName: {
|
||||
fontSize: 14,
|
||||
color: '#6F7D87',
|
||||
lineHeight: 20,
|
||||
},
|
||||
referenceLink: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: 'rgba(111, 125, 135, 0.08)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
referenceUrl: {
|
||||
fontSize: 14,
|
||||
color: '#2E3142',
|
||||
flex: 1,
|
||||
},
|
||||
referenceNote: {
|
||||
fontSize: 13,
|
||||
color: '#8A96A3',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 18,
|
||||
},
|
||||
disclaimerSection: {
|
||||
backgroundColor: 'rgba(255, 248, 225, 0.6)',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 193, 7, 0.2)',
|
||||
},
|
||||
disclaimerHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
disclaimerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#2E3142',
|
||||
marginLeft: 8,
|
||||
},
|
||||
disclaimerText: {
|
||||
fontSize: 14,
|
||||
color: '#5B6572',
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
890
app/fitness-rings-detail.tsx
Normal file
@@ -0,0 +1,890 @@
|
||||
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 { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
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';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
// 配置 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 { t, i18n } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
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(t('fitnessRingsDetail.errors.loadExerciseInfoPreference'), 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 = [
|
||||
t('fitnessRingsDetail.weekDays.monday'),
|
||||
t('fitnessRingsDetail.weekDays.tuesday'),
|
||||
t('fitnessRingsDetail.weekDays.wednesday'),
|
||||
t('fitnessRingsDetail.weekDays.thursday'),
|
||||
t('fitnessRingsDetail.weekDays.friday'),
|
||||
t('fitnessRingsDetail.weekDays.saturday'),
|
||||
t('fitnessRingsDetail.weekDays.sunday')
|
||||
];
|
||||
|
||||
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').locale(i18n.language === 'zh' ? 'zh-cn' : 'en');
|
||||
const dateFormat = t('fitnessRingsDetail.dateFormats.header', { defaultValue: 'YYYY年MM月DD日' });
|
||||
return dayJsDate.format(dateFormat);
|
||||
};
|
||||
|
||||
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(t('fitnessRingsDetail.errors.saveExerciseInfoPreference'), 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,
|
||||
{
|
||||
flex: 1,
|
||||
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
|
||||
backgroundColor: value > 0 ? color : '#E5E5EA',
|
||||
opacity: value > 0 ? 1 : 0.5,
|
||||
marginHorizontal: 0.5
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.chartLabels}>
|
||||
{chartData.map((_, index) => {
|
||||
// 只在关键时间点显示标签:0点、6点、12点、18点
|
||||
if (index === 0 || index === 6 || index === 12 || index === 18) {
|
||||
const hour = index;
|
||||
return (
|
||||
<Text key={index} style={styles.chartLabel}>
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// 对于不显示标签的小时,返回一个占位的View
|
||||
return <View key={index} style={styles.chartLabelSpacer} />;
|
||||
})}
|
||||
</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}>{t('fitnessRingsDetail.cards.activeCalories.title')}</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}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(activeEnergyBurned)}{t('fitnessRingsDetail.cards.activeCalories.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyCaloriesData.map(h => h.calories),
|
||||
Math.max(activeEnergyBurnedGoal / 24, 1),
|
||||
'#FF3B30',
|
||||
t('fitnessRingsDetail.cards.activeCalories.unit')
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 锻炼分钟卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</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}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleExerciseTime)}{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyExerciseData.map(h => h.minutes),
|
||||
Math.max(appleExerciseTimeGoal / 8, 1),
|
||||
'#FF9500',
|
||||
t('fitnessRingsDetail.cards.exerciseMinutes.unit')
|
||||
)}
|
||||
|
||||
{/* 锻炼分钟说明 */}
|
||||
{showExerciseInfo && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.exerciseInfo,
|
||||
{
|
||||
opacity: exerciseInfoAnim,
|
||||
transform: [
|
||||
{
|
||||
scale: exerciseInfoAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.95, 1],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={styles.exerciseTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.title')}</Text>
|
||||
<Text style={styles.exerciseDesc}>
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.description')}
|
||||
</Text>
|
||||
<Text style={styles.exerciseRecommendation}>
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
|
||||
<Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 活动小时数卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</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}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyStandData.map(h => h.hasStood),
|
||||
1,
|
||||
'#007AFF',
|
||||
t('fitnessRingsDetail.cards.standHours.unit')
|
||||
)}
|
||||
</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, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
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 }]}>{t('fitnessRingsDetail.stats.weeklyClosedRings')}</Text>
|
||||
<View style={styles.statValue}>
|
||||
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}{t('fitnessRingsDetail.stats.daysUnit')}</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: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
|
||||
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}>{t('fitnessRingsDetail.datePicker.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</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: 2,
|
||||
},
|
||||
chartBar: {
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
chartLabels: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 2,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
chartLabel: {
|
||||
fontSize: 10,
|
||||
color: '#8E8E93',
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
flex: 6, // 给显示标签的元素更多空间
|
||||
},
|
||||
chartLabelSpacer: {
|
||||
flex: 1, // 占位元素使用较少空间
|
||||
},
|
||||
// 锻炼信息样式
|
||||
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',
|
||||
},
|
||||
});
|
||||
981
app/food-library.tsx
Normal file
@@ -0,0 +1,981 @@
|
||||
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 { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
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 { saveNutritionToHealthKit } from '@/utils/health';
|
||||
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,
|
||||
Dimensions,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
export default function FoodLibraryScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const insets = useSafeAreaInsets();
|
||||
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 getMealTypeLabel = (type: MealType) => {
|
||||
const labels: Record<MealType, string> = {
|
||||
breakfast: t('foodLibrary.mealTypes.breakfast'),
|
||||
lunch: t('foodLibrary.mealTypes.lunch'),
|
||||
dinner: t('foodLibrary.mealTypes.dinner'),
|
||||
snack: t('foodLibrary.mealTypes.snack'),
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// 获取当前选中的分类
|
||||
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(
|
||||
t('foodLibrary.alerts.deleteFailed.title'),
|
||||
t('foodLibrary.alerts.deleteFailed.message')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理饮食记录
|
||||
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);
|
||||
|
||||
// 然后尝试同步到 HealthKit(非阻塞)
|
||||
// 提取蛋白质、脂肪和碳水化合物数据
|
||||
const { proteinGrams, fatGrams, carbohydrateGrams, mealTime } = dietRecordData;
|
||||
|
||||
if (proteinGrams !== undefined || fatGrams !== undefined || carbohydrateGrams !== undefined) {
|
||||
// 使用 catch 确保 HealthKit 同步失败不影响后端记录
|
||||
saveNutritionToHealthKit(
|
||||
{
|
||||
proteinGrams: proteinGrams || undefined,
|
||||
fatGrams: fatGrams || undefined,
|
||||
carbohydrateGrams: carbohydrateGrams || undefined
|
||||
},
|
||||
mealTime
|
||||
).catch(error => {
|
||||
// HealthKit 同步失败只记录日志,不影响用户体验
|
||||
console.error('HealthKit 营养数据同步失败(不影响记录):', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 记录成功后,刷新当天的营养数据
|
||||
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(
|
||||
t('foodLibrary.alerts.createFailed.title'),
|
||||
t('foodLibrary.alerts.createFailed.message')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭自定义食物弹窗
|
||||
const handleCloseCreateCustomFood = () => {
|
||||
setShowCreateCustomFood(false);
|
||||
};
|
||||
|
||||
// 餐次选择选项
|
||||
const mealOptions = [
|
||||
{ key: 'breakfast' as const, label: t('foodLibrary.mealTypes.breakfast'), color: '#FF6B35' },
|
||||
{ key: 'lunch' as const, label: t('foodLibrary.mealTypes.lunch'), color: '#4CAF50' },
|
||||
{ key: 'dinner' as const, label: t('foodLibrary.mealTypes.dinner'), color: '#2196F3' },
|
||||
{ key: 'snack' as const, label: t('foodLibrary.mealTypes.snack'), color: '#FF9800' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 头部 */}
|
||||
<HeaderBar
|
||||
title={t('foodLibrary.title')}
|
||||
onBack={() => router.back()}
|
||||
variant="default"
|
||||
right={
|
||||
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
|
||||
<Ionicons name="add-circle-outline" size={24} color={Colors.light.primary} />
|
||||
<Text style={styles.customButtonText}>{t('foodLibrary.custom')}</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={{ height: safeAreaTop }} />
|
||||
|
||||
{/* 搜索框 */}
|
||||
<View style={styles.searchWrapper}>
|
||||
<View style={styles.searchContainer}>
|
||||
<Ionicons name="search" size={20} color="#94A3B8" style={styles.searchIcon} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={t('foodLibrary.search.placeholder')}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText('')}>
|
||||
<Ionicons name="close-circle" size={18} color="#94A3B8" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 主要内容区域 - Split View Card */}
|
||||
<View style={styles.mainContentCard}>
|
||||
{loading && categories.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>{t('foodLibrary.loading')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
clearErrors();
|
||||
// 这里可以重新加载数据
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t('foodLibrary.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.splitViewContainer}>
|
||||
{/* 左侧分类导航 */}
|
||||
<View style={styles.categorySidebar}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.categoryListContent}>
|
||||
{categories.map((category) => (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.categoryItem,
|
||||
selectedCategoryId === category.id && styles.categoryItemActive
|
||||
]}
|
||||
onPress={() => {
|
||||
setSelectedCategoryId(category.id);
|
||||
if (searchText) {
|
||||
setSearchText('');
|
||||
clearResults();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={[
|
||||
styles.categoryIndicator,
|
||||
selectedCategoryId === category.id && styles.categoryIndicatorActive
|
||||
]} />
|
||||
<Text style={[
|
||||
styles.categoryText,
|
||||
selectedCategoryId === category.id && styles.categoryTextActive
|
||||
]}>
|
||||
{category.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 右侧食物列表 */}
|
||||
<View style={styles.foodListContainer}>
|
||||
{searchLoading ? (
|
||||
<View style={styles.searchLoadingContainer}>
|
||||
<ActivityIndicator size="small" color={Colors.light.primary} />
|
||||
<Text style={styles.searchLoadingText}>{t('foodLibrary.search.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.foodListContent}
|
||||
>
|
||||
{filteredFoods.map((food) => (
|
||||
<TouchableOpacity
|
||||
key={food.id}
|
||||
style={styles.foodItemCard}
|
||||
onPress={() => handleSelectFood(food)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Image
|
||||
style={styles.foodImage}
|
||||
source={{ uri: food.imageUrl || DEFAULT_IMAGE_FOOD }}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
<View style={styles.foodInfo}>
|
||||
<Text style={styles.foodName} numberOfLines={1}>{food.name}</Text>
|
||||
<Text style={styles.foodCalories}>
|
||||
{food.calories} <Text style={styles.unitText}>kcal/{food.unit}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.addButton}>
|
||||
<Ionicons name="add" size={16} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
{filteredFoods.length === 0 && !searchLoading && (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/ImageEmpty.png')}
|
||||
style={{ width: 80, height: 80, opacity: 0.5, marginBottom: 10 }}
|
||||
/>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchText ? t('foodLibrary.search.empty') : t('foodLibrary.search.noData')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 底部留白,防止被底部栏遮挡 */}
|
||||
<View style={{ height: 100 }} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 底部操作栏 - 悬浮样式 */}
|
||||
<View style={[styles.bottomBarContainer, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||
{/* 已选择食物概览 (如果有选择) */}
|
||||
{selectedFoodItems.length > 0 && (
|
||||
<View style={styles.selectedPreviewContainer}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.selectedList}>
|
||||
{selectedFoodItems.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={styles.selectedChip}
|
||||
onPress={() => handleRemoveSelectedFood(item.id)}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.food.imageUrl || DEFAULT_IMAGE_FOOD }}
|
||||
style={styles.selectedChipImage}
|
||||
/>
|
||||
<Text style={styles.selectedChipText}>{item.amount}{item.unit}</Text>
|
||||
<View style={styles.selectedChipClose}>
|
||||
<Ionicons name="close" size={10} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View style={styles.totalCaloriesBadge}>
|
||||
<Text style={styles.totalCaloriesText}>{totalCalories} kcal</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.mealSelectorButton}
|
||||
onPress={() => setShowMealSelector(true)}
|
||||
>
|
||||
<View style={[
|
||||
styles.mealDot,
|
||||
{ backgroundColor: mealOptions.find(option => option.key === currentMealType)?.color || '#FF6B35' }
|
||||
]} />
|
||||
<Text style={styles.mealSelectorText}>{getMealTypeLabel(currentMealType)}</Text>
|
||||
<Ionicons name="chevron-down" size={16} color="#1c1f3a" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.confirmButton,
|
||||
(selectedFoodItems.length === 0 || isRecording) && styles.confirmButtonDisabled
|
||||
]}
|
||||
disabled={selectedFoodItems.length === 0 || isRecording}
|
||||
onPress={handleRecordDiet}
|
||||
>
|
||||
{isRecording ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.confirmButtonText}>{t('foodLibrary.actions.record')} ({selectedFoodItems.length})</Text>
|
||||
<Ionicons name="arrow-forward" size={18} color="#FFF" />
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 餐次选择弹窗 */}
|
||||
<Modal
|
||||
visible={showMealSelector}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowMealSelector(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.modalBackdrop}
|
||||
onPress={() => setShowMealSelector(false)}
|
||||
/>
|
||||
<View style={[styles.modalContent, { paddingBottom: insets.bottom + 20 }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{t('foodLibrary.actions.selectMeal')}</Text>
|
||||
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
|
||||
<Ionicons name="close-circle" size={24} color="#94A3B8" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.mealOptionsList}>
|
||||
{mealOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
style={[
|
||||
styles.mealOptionItem,
|
||||
currentMealType === option.key && styles.mealOptionItemActive
|
||||
]}
|
||||
onPress={() => handleMealTypeSelect(option.key)}
|
||||
>
|
||||
<View style={[styles.mealOptionDot, { backgroundColor: option.color }]} />
|
||||
<Text style={[
|
||||
styles.mealOptionLabel,
|
||||
currentMealType === option.key && styles.mealOptionLabelActive
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{currentMealType === option.key && (
|
||||
<Ionicons name="checkmark-circle" size={20} color={Colors.light.primary} />
|
||||
)}
|
||||
</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: '#f3f4fb', // Matches sleep-detail background
|
||||
},
|
||||
customButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
},
|
||||
customButtonText: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.primary,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
// Search Area
|
||||
searchWrapper: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
zIndex: 10,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.05)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliRegular',
|
||||
padding: 0, // Remove Android default padding
|
||||
},
|
||||
// Main Content Card
|
||||
mainContentCard: {
|
||||
flex: 1,
|
||||
marginHorizontal: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 16,
|
||||
elevation: 5,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
splitViewContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
// Sidebar
|
||||
categorySidebar: {
|
||||
width: 90,
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#F1F5F9',
|
||||
},
|
||||
categoryListContent: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
categoryItem: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
categoryItemActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
categoryIndicator: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
width: 3,
|
||||
borderTopRightRadius: 3,
|
||||
borderBottomRightRadius: 3,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
categoryIndicatorActive: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 13,
|
||||
color: '#64748B',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
categoryTextActive: {
|
||||
color: Colors.light.primary,
|
||||
fontFamily: 'AliBold',
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Food List
|
||||
foodListContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
foodListContent: {
|
||||
padding: 16,
|
||||
},
|
||||
foodItemCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F1F5F9',
|
||||
shadowColor: 'rgba(148, 163, 184, 0.1)',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
foodImage: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
marginRight: 12,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
foodInfo: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
foodName: {
|
||||
fontSize: 15,
|
||||
color: '#1E293B',
|
||||
fontFamily: 'AliBold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
foodCalories: {
|
||||
fontSize: 13,
|
||||
color: Colors.light.primary,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
unitText: {
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
addButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: Colors.light.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Empty States
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
color: '#EF4444',
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 20,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
searchLoadingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
searchLoadingText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
// Bottom Bar
|
||||
bottomBarContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
paddingTop: 12,
|
||||
paddingHorizontal: 20,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.1)',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
selectedPreviewContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
selectedList: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
selectedChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F1F5F9',
|
||||
borderRadius: 16,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 8,
|
||||
paddingVertical: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
selectedChipImage: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
marginRight: 6,
|
||||
},
|
||||
selectedChipText: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontFamily: 'AliBold',
|
||||
marginRight: 6,
|
||||
},
|
||||
selectedChipClose: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#CBD5E1',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
totalCaloriesBadge: {
|
||||
marginLeft: 'auto',
|
||||
backgroundColor: '#EEF2FF',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
totalCaloriesText: {
|
||||
fontSize: 12,
|
||||
color: Colors.light.primary,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
mealSelectorButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderRadius: 24,
|
||||
height: 48,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
},
|
||||
mealDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
mealSelectorText: {
|
||||
fontSize: 15,
|
||||
color: '#1E293B',
|
||||
fontFamily: 'AliBold',
|
||||
marginRight: 6,
|
||||
},
|
||||
confirmButton: {
|
||||
flex: 2,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 24,
|
||||
height: 48,
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
gap: 8,
|
||||
},
|
||||
confirmButtonDisabled: {
|
||||
backgroundColor: '#94A3B8',
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
confirmButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#FFF',
|
||||
fontFamily: 'AliBold',
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Modal Styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
padding: 24,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
color: '#1E293B',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
mealOptionsList: {
|
||||
gap: 12,
|
||||
},
|
||||
mealOptionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderWidth: 1,
|
||||
borderColor: '#F1F5F9',
|
||||
},
|
||||
mealOptionItemActive: {
|
||||
backgroundColor: '#EEF2FF',
|
||||
borderColor: Colors.light.primary,
|
||||
},
|
||||
mealOptionDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 16,
|
||||
},
|
||||
mealOptionLabel: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: '#475569',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
mealOptionLabelActive: {
|
||||
color: '#1E293B',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
1274
app/food/analysis-result.tsx
Normal file
763
app/food/camera.tsx
Normal file
@@ -0,0 +1,763 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
export default function FoodCameraScreen() {
|
||||
const { t } = useI18n();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
|
||||
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 [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
// 餐次选择选项
|
||||
const mealOptions = [
|
||||
{ key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), icon: '☀️' },
|
||||
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), icon: '🌤️' },
|
||||
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), icon: '🌙' },
|
||||
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), icon: '🍎' },
|
||||
];
|
||||
|
||||
// 计算固定的相机高度
|
||||
const cameraHeight = useMemo(() => {
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
// 计算固定占用的高度
|
||||
const headerHeight = insets.top + 40; // HeaderBar 高度
|
||||
const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域
|
||||
const shotsRowHeight = 12 + 88; // MealType 区域
|
||||
const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域
|
||||
const margins = 12 + 12; // cameraCard 的上下边距
|
||||
|
||||
// 可用于相机的高度
|
||||
const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins;
|
||||
|
||||
// 确保最小高度为 300,最大不超过屏幕的 55%
|
||||
return Math.max(300, Math.min(availableHeight, screenHeight * 0.55));
|
||||
}, [insets.top, insets.bottom]);
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar
|
||||
title={t('foodCamera.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
<View style={[styles.loadingContainer, { paddingTop: insets.top + 40 }]}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
|
||||
<HeaderBar
|
||||
title={t('foodCamera.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent
|
||||
/>
|
||||
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
|
||||
<Ionicons name="camera-outline" size={64} color="#94a3b8" style={{ marginBottom: 20 }} />
|
||||
<Text style={styles.permissionTitle}>
|
||||
{t('foodCamera.permission.title')}
|
||||
</Text>
|
||||
<Text style={styles.permissionTip}>
|
||||
{t('foodCamera.permission.description')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={requestPermission}
|
||||
>
|
||||
<Text style={styles.permissionBtnText}>
|
||||
{t('foodCamera.permission.button')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 切换相机前后摄像头
|
||||
function toggleCameraFacing() {
|
||||
setFacing(current => (current === 'back' ? 'front' : 'back'));
|
||||
}
|
||||
|
||||
// 拍摄照片
|
||||
const takePicture = async () => {
|
||||
if (cameraRef.current && !isCapturing) {
|
||||
setIsCapturing(true);
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.8,
|
||||
base64: false,
|
||||
});
|
||||
|
||||
if (photo) {
|
||||
console.log('照片拍摄成功:', photo.uri);
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (isLoggedIn) {
|
||||
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('拍照失败:', error);
|
||||
Alert.alert(t('foodCamera.alerts.captureFailed.title'), t('foodCamera.alerts.captureFailed.message'));
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 从相册选择照片
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const imageUri = result.assets[0].uri;
|
||||
console.log('从相册选择的照片:', imageUri);
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (isLoggedIn) {
|
||||
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择照片失败:', error);
|
||||
Alert.alert(t('foodCamera.alerts.pickFailed.title'), t('foodCamera.alerts.pickFailed.message'));
|
||||
}
|
||||
};
|
||||
|
||||
// 餐次选择
|
||||
const handleMealTypeChange = (mealType: MealType) => {
|
||||
setCurrentMealType(mealType);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||
|
||||
<HeaderBar
|
||||
title={t('foodCamera.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
right={
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowInstructionModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.infoButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="help-circle-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
|
||||
<Ionicons name="help-circle-outline" size={24} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={{ height: insets.top + 40 }} />
|
||||
|
||||
{/* Top Meta Info */}
|
||||
<View style={styles.topMeta}>
|
||||
<View style={styles.metaBadge}>
|
||||
<Text style={styles.metaBadgeText}>{t('foodCamera.hint')}</Text>
|
||||
</View>
|
||||
<Text style={styles.metaTitle}>
|
||||
{t('nutritionRecords.listTitle')}
|
||||
</Text>
|
||||
<Text style={styles.metaSubtitle}>
|
||||
{t('foodCamera.guide.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Camera Card */}
|
||||
<View style={styles.cameraCard}>
|
||||
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={styles.cameraView}
|
||||
facing={facing}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.1)']}
|
||||
style={styles.cameraOverlay}
|
||||
/>
|
||||
{/* Viewfinder Overlay */}
|
||||
<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>
|
||||
|
||||
{/* Meal Type Selector (Replacing Shots Row) */}
|
||||
<View style={styles.shotsRow}>
|
||||
{mealOptions.map((option) => {
|
||||
const active = currentMealType === option.key;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
onPress={() => handleMealTypeChange(option.key)}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.shotCard, active && styles.shotCardActive]}
|
||||
>
|
||||
<Text style={styles.mealTypeIcon}>{option.icon}</Text>
|
||||
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||
<View style={styles.bottomActions}>
|
||||
{/* Album Button */}
|
||||
<TouchableOpacity
|
||||
onPress={pickImageFromGallery}
|
||||
disabled={isCapturing}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>
|
||||
{t('foodCamera.buttons.album')}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>
|
||||
{t('foodCamera.buttons.album')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Capture Button */}
|
||||
<TouchableOpacity
|
||||
onPress={takePicture}
|
||||
disabled={isCapturing}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.captureBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<View style={styles.captureOuterRing}>
|
||||
{isCapturing ? (
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
) : (
|
||||
<View style={styles.captureInner} />
|
||||
)}
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
|
||||
<View style={styles.captureOuterRing}>
|
||||
{isCapturing ? (
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
) : (
|
||||
<View style={styles.captureInner} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Flip Button */}
|
||||
<TouchableOpacity
|
||||
onPress={toggleCameraFacing}
|
||||
disabled={isCapturing}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>
|
||||
{t('foodCamera.buttons.capture')}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>
|
||||
{t('foodCamera.buttons.capture')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Instruction Modal */}
|
||||
<Modal
|
||||
visible={showInstructionModal}
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
onRequestClose={() => setShowInstructionModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.instructionModal}>
|
||||
<Text style={styles.instructionTitle}>{t('foodCamera.guide.title')}</Text>
|
||||
|
||||
<View style={styles.exampleContainer}>
|
||||
{/* Good Example */}
|
||||
<View style={styles.exampleItem}>
|
||||
<View style={styles.exampleImagePlaceholder}>
|
||||
<View style={styles.checkmarkContainer}>
|
||||
<Ionicons name="checkmark" size={20} color="#FFF" />
|
||||
</View>
|
||||
<Image
|
||||
style={styles.exampleImage}
|
||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-right.jpeg' }}
|
||||
contentFit="cover"
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.exampleText}>{t('foodCamera.guide.good')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Bad Example */}
|
||||
<View style={styles.exampleItem}>
|
||||
<View style={styles.exampleImagePlaceholder}>
|
||||
<View style={styles.crossContainer}>
|
||||
<Ionicons name="close" size={20} color="#FFF" />
|
||||
</View>
|
||||
<Image
|
||||
style={styles.exampleImage}
|
||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/function/food-wrong.jpeg' }}
|
||||
contentFit="cover"
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.exampleText}>{t('foodCamera.guide.bad')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.instructionDescription}>
|
||||
{t('foodCamera.guide.description')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.knowButton}
|
||||
onPress={() => setShowInstructionModal(false)}
|
||||
>
|
||||
<Text style={styles.knowButtonText}>{t('foodCamera.guide.button')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topMeta: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 6,
|
||||
},
|
||||
metaBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#e0f2fe',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
metaBadgeText: {
|
||||
color: '#0369a1',
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
metaTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
metaSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
},
|
||||
cameraCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 12,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
cameraFrame: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0b172a',
|
||||
position: 'relative',
|
||||
},
|
||||
cameraView: {
|
||||
flex: 1,
|
||||
},
|
||||
cameraOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
},
|
||||
viewfinderOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
margin: 20,
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderWidth: 4,
|
||||
borderRadius: 2,
|
||||
},
|
||||
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,
|
||||
},
|
||||
shotsRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 8,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
shotCard: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingVertical: 12,
|
||||
gap: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shotCardActive: {
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: '#ecfeff',
|
||||
},
|
||||
mealTypeIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
shotLabel: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
},
|
||||
shotLabelActive: {
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
bottomBar: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
fallbackCaptureBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
captureOuterRing: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
minWidth: 88,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fallbackSecondaryBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
infoButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackInfoButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
permissionCard: {
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 18,
|
||||
padding: 24,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
permissionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
permissionTip: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
permissionBtn: {
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
permissionBtnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
fontSize: 16,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
instructionModal: {
|
||||
backgroundColor: '#FFF',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
instructionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 24,
|
||||
},
|
||||
exampleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
exampleItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
exampleImagePlaceholder: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#F1F5F9',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
exampleImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
exampleText: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
fontWeight: '500',
|
||||
},
|
||||
checkmarkContainer: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#22c55e',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
crossContainer: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#ef4444',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
instructionDescription: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
color: '#334155',
|
||||
marginBottom: 24,
|
||||
lineHeight: 22,
|
||||
},
|
||||
knowButton: {
|
||||
backgroundColor: '#0f172a',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 32,
|
||||
width: '100%',
|
||||
},
|
||||
knowButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
631
app/food/food-recognition.tsx
Normal file
@@ -0,0 +1,631 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { recognizeFood } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
SlideInDown,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FoodRecognitionScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
|
||||
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();
|
||||
|
||||
// Auth & VIP hooks
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { handleServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
|
||||
// Animation values
|
||||
const progressValue = useSharedValue(0);
|
||||
const pulseValue = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 'uploading') {
|
||||
progressValue.value = withTiming(0.4, { duration: 2000 });
|
||||
startPulse();
|
||||
} else if (currentStep === 'recognizing') {
|
||||
progressValue.value = withTiming(0.9, { duration: 3000 });
|
||||
} else if (currentStep === 'completed') {
|
||||
progressValue.value = withTiming(1, { duration: 500 });
|
||||
stopPulse();
|
||||
} else if (currentStep === 'failed') {
|
||||
stopPulse();
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
const startPulse = () => {
|
||||
pulseValue.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
const stopPulse = () => {
|
||||
pulseValue.value = withTiming(1);
|
||||
};
|
||||
|
||||
const addLog = (message: string) => {
|
||||
setRecognitionLogs(prev => [...prev, message]);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageUri) return;
|
||||
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const canAccess = handleServiceAccess(
|
||||
() => {}, // Allowed
|
||||
() => openMembershipModal() // Denied
|
||||
);
|
||||
|
||||
if (!canAccess) return;
|
||||
|
||||
try {
|
||||
setShowRecognitionProcess(true);
|
||||
setRecognitionLogs([]);
|
||||
setCurrentStep('uploading');
|
||||
dispatch(setLoading(true));
|
||||
|
||||
addLog(t('foodRecognition.logs.uploading'));
|
||||
|
||||
const { url } = await upload(
|
||||
{ uri: imageUri, name: 'food-image.jpg', type: 'image/jpeg' },
|
||||
{ prefix: 'food-images/' }
|
||||
);
|
||||
|
||||
addLog(t('foodRecognition.logs.uploadSuccess'));
|
||||
addLog(t('foodRecognition.logs.analyzing'));
|
||||
setCurrentStep('recognizing');
|
||||
|
||||
const recognitionResult = await recognizeFood({
|
||||
imageUrls: [url]
|
||||
});
|
||||
|
||||
console.log('食物识别结果:', recognitionResult);
|
||||
|
||||
if (!recognitionResult.isFoodDetected) {
|
||||
addLog(t('foodRecognition.logs.failed'));
|
||||
addLog(`💭 ${recognitionResult.nonFoodMessage || recognitionResult.analysisText}`);
|
||||
setCurrentStep('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
addLog(t('foodRecognition.logs.analysisSuccess'));
|
||||
addLog(t('foodRecognition.logs.confidence', { value: recognitionResult.confidence }));
|
||||
addLog(t('foodRecognition.logs.itemsFound', { count: recognitionResult.items.length }));
|
||||
|
||||
setCurrentStep('completed');
|
||||
|
||||
const recognitionId = `recognition_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
dispatch(saveRecognitionResult({
|
||||
id: recognitionId,
|
||||
result: recognitionResult
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
router.replace(`/food/analysis-result?imageUri=${encodeURIComponent(url)}&mealType=${mealType}&recognitionId=${recognitionId}`);
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('食物识别失败', error);
|
||||
addLog(t('foodRecognition.logs.error'));
|
||||
addLog(`💥 ${error instanceof Error ? error.message : t('foodRecognition.errors.unknown')}`);
|
||||
setCurrentStep('failed');
|
||||
dispatch(setError(t('foodRecognition.errors.generic')));
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setShowRecognitionProcess(false);
|
||||
setCurrentStep('idle');
|
||||
setRecognitionLogs([]);
|
||||
dispatch(setError(null));
|
||||
progressValue.value = 0;
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (showRecognitionProcess && currentStep !== 'failed' && currentStep !== 'completed') {
|
||||
Alert.alert(
|
||||
t('foodRecognition.alerts.recognizing.title'),
|
||||
t('foodRecognition.alerts.recognizing.message'),
|
||||
[
|
||||
{ text: t('foodRecognition.alerts.recognizing.continue'), style: 'cancel' },
|
||||
{ text: t('foodRecognition.alerts.recognizing.back'), style: 'destructive', onPress: () => router.back() }
|
||||
]
|
||||
);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const pulseStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: pulseValue.value }]
|
||||
}));
|
||||
|
||||
const progressBarStyle = useAnimatedStyle(() => ({
|
||||
width: `${progressValue.value * 100}%`
|
||||
}));
|
||||
|
||||
if (!imageUri) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title={t('foodRecognition.title')} onBack={router.back} />
|
||||
<View style={[styles.errorContainer, { paddingTop: insets.top + 60 }]}>
|
||||
<Text style={styles.errorText}>{t('foodRecognition.errors.noImage')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#fefefe', '#f4f7fb', '#eff6ff']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title={showRecognitionProcess ? t('foodRecognition.header.recognizing') : t('foodRecognition.header.confirm')}
|
||||
onBack={handleGoBack}
|
||||
transparent
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 20 }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Image Preview Card */}
|
||||
<View style={styles.imageCard}>
|
||||
<View style={styles.imageFrame}>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.mainImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.3)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{mealType && (
|
||||
<View style={styles.mealBadge}>
|
||||
<GlassView
|
||||
style={styles.mealBadgeGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(0,0,0,0.4)"
|
||||
>
|
||||
<Text style={styles.mealBadgeText}>{getMealTypeLabel(mealType, t)}</Text>
|
||||
</GlassView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status / Action Area */}
|
||||
{!showRecognitionProcess ? (
|
||||
<Animated.View entering={FadeIn.duration(400).delay(200)}>
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.iconCircle}>
|
||||
<Ionicons name="sparkles" size={24} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.infoTitle}>{t('foodRecognition.info.title')}</Text>
|
||||
<Text style={styles.infoDesc}>
|
||||
{t('foodRecognition.info.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirm}
|
||||
activeOpacity={0.8}
|
||||
style={styles.primaryButtonWrapper}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.primaryButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor={colors.primary}
|
||||
isInteractive
|
||||
>
|
||||
<Ionicons name="scan-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.primaryButtonText}>{t('foodRecognition.actions.start')}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.primaryButton, { backgroundColor: colors.primary }]}>
|
||||
<Ionicons name="scan-outline" size={20} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.primaryButtonText}>{t('foodRecognition.actions.start')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={SlideInDown.springify().damping(20)}
|
||||
style={styles.processContainer}
|
||||
>
|
||||
{/* Progress Status Card */}
|
||||
<View style={styles.progressCard}>
|
||||
<View style={styles.progressHeader}>
|
||||
<Animated.View style={[styles.statusIconContainer, pulseStyle]}>
|
||||
<View style={[
|
||||
styles.statusIcon,
|
||||
{ backgroundColor: getStatusColor(currentStep, colors) }
|
||||
]}>
|
||||
{currentStep === 'completed' ? (
|
||||
<Ionicons name="checkmark" size={24} color="#fff" />
|
||||
) : currentStep === 'failed' ? (
|
||||
<Ionicons name="close" size={24} color="#fff" />
|
||||
) : (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
<View style={styles.statusTextContainer}>
|
||||
<Text style={styles.statusTitle}>
|
||||
{getStatusTitle(currentStep, t)}
|
||||
</Text>
|
||||
<Text style={styles.statusSubtitle}>
|
||||
{getStatusSubtitle(currentStep, t)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{(currentStep === 'uploading' || currentStep === 'recognizing') && (
|
||||
<View style={styles.progressBarBg}>
|
||||
<Animated.View style={[styles.progressBarFill, { backgroundColor: colors.primary }, progressBarStyle]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Log Console */}
|
||||
<View style={styles.logsCard}>
|
||||
<View style={styles.logsHeader}>
|
||||
<Ionicons name="terminal-outline" size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.logsTitle}>{t('foodRecognition.actions.logs')}</Text>
|
||||
</View>
|
||||
<ScrollView style={styles.logsScroll} nestedScrollEnabled>
|
||||
{recognitionLogs.map((log, idx) => (
|
||||
<Animated.View
|
||||
key={idx}
|
||||
entering={FadeIn.duration(300)}
|
||||
style={styles.logRow}
|
||||
>
|
||||
<Text style={styles.logText}>{log}</Text>
|
||||
</Animated.View>
|
||||
))}
|
||||
{recognitionLogs.length === 0 && (
|
||||
<Text style={styles.logPlaceholder}>{t('foodRecognition.actions.logsPlaceholder')}</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{currentStep === 'failed' && (
|
||||
<TouchableOpacity
|
||||
onPress={handleRetry}
|
||||
activeOpacity={0.8}
|
||||
style={styles.retryButton}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t('foodRecognition.actions.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getMealTypeLabel(type: string, t: any): string {
|
||||
const map: Record<string, string> = {
|
||||
breakfast: t('foodRecognition.mealTypes.breakfast'),
|
||||
lunch: t('foodRecognition.mealTypes.lunch'),
|
||||
dinner: t('foodRecognition.mealTypes.dinner'),
|
||||
snack: t('foodRecognition.mealTypes.snack'),
|
||||
};
|
||||
return map[type] || t('foodRecognition.mealTypes.unknown');
|
||||
}
|
||||
|
||||
function getStatusColor(step: string, colors: any) {
|
||||
switch (step) {
|
||||
case 'completed': return colors.success;
|
||||
case 'failed': return colors.danger;
|
||||
default: return colors.primary;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTitle(step: string, t: any) {
|
||||
switch (step) {
|
||||
case 'idle': return t('foodRecognition.status.idle.title');
|
||||
case 'uploading': return t('foodRecognition.status.uploading.title');
|
||||
case 'recognizing': return t('foodRecognition.status.recognizing.title');
|
||||
case 'completed': return t('foodRecognition.status.completed.title');
|
||||
case 'failed': return t('foodRecognition.status.failed.title');
|
||||
default: return t('foodRecognition.status.processing.title');
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusSubtitle(step: string, t: any) {
|
||||
switch (step) {
|
||||
case 'uploading': return t('foodRecognition.status.uploading.subtitle');
|
||||
case 'recognizing': return t('foodRecognition.status.recognizing.subtitle');
|
||||
case 'completed': return t('foodRecognition.status.completed.subtitle');
|
||||
case 'failed': return t('foodRecognition.status.failed.subtitle');
|
||||
default: return t('foodRecognition.status.processing.subtitle');
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: '#64748b',
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
imageCard: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 24,
|
||||
elevation: 8,
|
||||
marginBottom: 24,
|
||||
},
|
||||
imageFrame: {
|
||||
width: '100%',
|
||||
aspectRatio: 1, // Square image or 4:3
|
||||
backgroundColor: '#f1f5f9',
|
||||
position: 'relative',
|
||||
},
|
||||
mainImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mealBadge: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mealBadgeGlass: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
mealBadgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#eff6ff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoDesc: {
|
||||
fontSize: 15,
|
||||
color: '#64748b',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
},
|
||||
actionButtons: {
|
||||
width: '100%',
|
||||
},
|
||||
primaryButtonWrapper: {
|
||||
width: '100%',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
backgroundColor: '#0284c7', // darker sky-600
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
},
|
||||
// Process styles
|
||||
processContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
progressCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
progressHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statusIconContainer: {
|
||||
marginRight: 16,
|
||||
},
|
||||
statusIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
statusTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
},
|
||||
progressBarBg: {
|
||||
height: 6,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
},
|
||||
logsCard: {
|
||||
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.8)',
|
||||
minHeight: 150,
|
||||
},
|
||||
logsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
opacity: 0.7,
|
||||
},
|
||||
logsTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#64748b',
|
||||
marginLeft: 6,
|
||||
},
|
||||
logsScroll: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
logRow: {
|
||||
marginBottom: 6,
|
||||
},
|
||||
logText: {
|
||||
fontSize: 13,
|
||||
color: '#334155',
|
||||
lineHeight: 18,
|
||||
fontFamily: 'Menlo', // Monospace if available
|
||||
},
|
||||
logPlaceholder: {
|
||||
fontSize: 13,
|
||||
color: '#94a3b8',
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
marginTop: 20,
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 20,
|
||||
backgroundColor: '#fff',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
904
app/food/nutrition-analysis-history.tsx
Normal file
@@ -0,0 +1,904 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
deleteNutritionAnalysisRecord,
|
||||
getNutritionAnalysisRecords,
|
||||
type GetNutritionRecordsParams,
|
||||
type NutritionAnalysisRecord,
|
||||
type NutritionItem
|
||||
} from '@/services/nutritionLabelAnalysis';
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
BackHandler,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function NutritionAnalysisHistoryScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const router = useRouter();
|
||||
|
||||
const [records, setRecords] = useState<NutritionAnalysisRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 处理Android返回键关闭图片预览
|
||||
useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showImagePreview) {
|
||||
setShowImagePreview(false);
|
||||
return true; // 阻止默认返回行为
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [showImagePreview]);
|
||||
|
||||
// 获取历史记录
|
||||
const fetchRecords = useCallback(async (page: number = 1, isRefresh: boolean = false, currentStatusFilter?: string) => {
|
||||
try {
|
||||
// 清除之前的错误
|
||||
setError(null);
|
||||
|
||||
const params: GetNutritionRecordsParams = {
|
||||
page,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
// 使用传入的筛选条件或当前状态
|
||||
const filterToUse = currentStatusFilter !== undefined ? currentStatusFilter : statusFilter;
|
||||
if (filterToUse) {
|
||||
params.status = filterToUse;
|
||||
}
|
||||
|
||||
const response = await getNutritionAnalysisRecords(params);
|
||||
|
||||
console.log('response', JSON.stringify(response));
|
||||
|
||||
if (response.code === 0) {
|
||||
const newRecords = response.data.records;
|
||||
|
||||
if (isRefresh || page === 1) {
|
||||
setRecords(newRecords);
|
||||
} else {
|
||||
setRecords(prev => [...prev, ...newRecords]);
|
||||
}
|
||||
|
||||
setTotal(response.data.total);
|
||||
setHasMore(page < response.data.totalPages);
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
const errorMessage = response.message || t('nutritionAnalysisHistory.errors.fetchFailed');
|
||||
setError(errorMessage);
|
||||
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 获取历史记录失败:', error);
|
||||
const errorMessage = t('nutritionAnalysisHistory.errors.fetchFailedRetry');
|
||||
setError(errorMessage);
|
||||
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
// 初始加载 - 只在组件挂载时执行一次
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchRecords(1, true);
|
||||
}, []); // 移除 fetchRecords 依赖,避免循环
|
||||
|
||||
// 筛选条件变化时的处理
|
||||
useEffect(() => {
|
||||
// 只有在非初始加载时才执行
|
||||
if (!loading) {
|
||||
setLoading(true);
|
||||
setCurrentPage(1);
|
||||
fetchRecords(1, true, statusFilter);
|
||||
}
|
||||
}, [statusFilter]); // 只依赖 statusFilter
|
||||
|
||||
// 下拉刷新
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
fetchRecords(1, true);
|
||||
}, []); // 移除 fetchRecords 依赖
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!hasMore || loadingMore || loading || error) return; // 添加错误状态检查
|
||||
|
||||
setLoadingMore(true);
|
||||
fetchRecords(currentPage + 1, false);
|
||||
}, [hasMore, loadingMore, loading, currentPage, error]); // 移除 fetchRecords 依赖,添加 error 依赖
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpanded = useCallback((id: number) => {
|
||||
triggerLightHaptic();
|
||||
setExpandedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '#4CAF50';
|
||||
case 'failed':
|
||||
return '#F44336';
|
||||
case 'processing':
|
||||
return '#FF9800';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return t('nutritionAnalysisHistory.status.success');
|
||||
case 'failed':
|
||||
return t('nutritionAnalysisHistory.status.failed');
|
||||
case 'processing':
|
||||
return t('nutritionAnalysisHistory.status.processing');
|
||||
default:
|
||||
return t('nutritionAnalysisHistory.status.unknown');
|
||||
}
|
||||
};
|
||||
|
||||
// 从营养数据中提取主要营养素的辅助函数
|
||||
const getMainNutrients = (data: NutritionItem[]) => {
|
||||
const energy = data.find(item => item.key === 'energy_kcal');
|
||||
const protein = data.find(item => item.key === 'protein');
|
||||
const carbs = data.find(item => item.key === 'carbohydrate');
|
||||
const fat = data.find(item => item.key === 'fat');
|
||||
|
||||
return {
|
||||
energy: energy?.value || '',
|
||||
protein: protein?.value || '',
|
||||
carbs: carbs?.value || '',
|
||||
fat: fat?.value || ''
|
||||
};
|
||||
};
|
||||
|
||||
// 处理图片预览
|
||||
const handleImagePreview = useCallback((imageUrl: string) => {
|
||||
triggerLightHaptic();
|
||||
setPreviewImageUri(imageUrl);
|
||||
setShowImagePreview(true);
|
||||
}, []);
|
||||
|
||||
// 处理删除记录
|
||||
const handleDeleteRecord = useCallback((recordId: number) => {
|
||||
Alert.alert(
|
||||
t('nutritionAnalysisHistory.delete.confirmTitle'),
|
||||
t('nutritionAnalysisHistory.delete.confirmMessage'),
|
||||
[
|
||||
{
|
||||
text: t('nutritionAnalysisHistory.delete.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: t('nutritionAnalysisHistory.delete.delete'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setDeletingId(recordId);
|
||||
await deleteNutritionAnalysisRecord(recordId);
|
||||
|
||||
// 从本地状态中移除删除的记录
|
||||
setRecords(prev => prev.filter(record => record.id !== recordId));
|
||||
setTotal(prev => Math.max(0, prev - 1));
|
||||
|
||||
// 触发轻微震动反馈
|
||||
triggerLightHaptic();
|
||||
|
||||
// 显示成功提示
|
||||
Alert.alert(t('nutritionAnalysisHistory.delete.successTitle'), t('nutritionAnalysisHistory.delete.successMessage'));
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 删除记录失败:', error);
|
||||
Alert.alert(t('nutritionAnalysisHistory.errors.error'), t('nutritionAnalysisHistory.errors.deleteFailed'));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 渲染历史记录项
|
||||
const renderRecordItem = useCallback(({ item }: { item: NutritionAnalysisRecord }) => {
|
||||
const isExpanded = expandedItems.has(item.id);
|
||||
const isSuccess = item.status === 'success';
|
||||
|
||||
return (
|
||||
<View style={styles.recordItem}>
|
||||
{/* 头部信息 */}
|
||||
<View style={styles.recordHeader}>
|
||||
<View style={styles.recordInfo}>
|
||||
{isSuccess && (
|
||||
<Text style={styles.recordTitle}>
|
||||
{t('nutritionAnalysisHistory.recognized', { count: item.nutritionCount })}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.recordDate}>
|
||||
{dayjs(item.createdAt).format(t('nutritionAnalysisHistory.dateFormat'))}
|
||||
</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
|
||||
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteRecord(item.id)}
|
||||
disabled={deletingId === item.id}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassDeleteButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(244, 67, 54, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{deletingId === item.id ? (
|
||||
<ActivityIndicator size="small" color="#F44336" />
|
||||
) : (
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.glassDeleteButton, styles.fallbackDeleteButton]}>
|
||||
{deletingId === item.id ? (
|
||||
<ActivityIndicator size="small" color="#F44336" />
|
||||
) : (
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 图片预览 */}
|
||||
{item.imageUrl && (
|
||||
<TouchableOpacity
|
||||
style={styles.imageContainer}
|
||||
onPress={() => handleImagePreview(item.imageUrl)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
style={styles.thumbnail}
|
||||
contentFit="cover"
|
||||
/>
|
||||
{/* 预览提示图标 */}
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={16} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 分析结果摘要 */}
|
||||
{isSuccess && item.analysisResult && item.analysisResult.data && item.analysisResult.data.length > 0 && (
|
||||
<View style={styles.summaryContainer}>
|
||||
<View style={styles.nutritionSummary}>
|
||||
{(() => {
|
||||
const mainNutrients = getMainNutrients(item.analysisResult.data);
|
||||
return (
|
||||
<>
|
||||
{mainNutrients.energy && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.energy')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.protein && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.protein')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.carbs && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.carbs')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.fat && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.fat')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.fat}</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 失败信息 */}
|
||||
{!isSuccess && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle-outline" size={20} color="#F44336" />
|
||||
<Text style={styles.errorMessage}>{item.message}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.expandButton}
|
||||
onPress={() => toggleExpanded(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.expandButtonText}>
|
||||
{isExpanded ? t('nutritionAnalysisHistory.actions.collapse') : t('nutritionAnalysisHistory.actions.expand')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
|
||||
size={16}
|
||||
color={Colors.light.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 详细信息 */}
|
||||
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
|
||||
<View style={styles.detailsContainer}>
|
||||
<Text style={styles.detailsTitle}>{t('nutritionAnalysisHistory.details.title')}</Text>
|
||||
{item.analysisResult.data.map((nutritionItem: NutritionItem) => (
|
||||
<View key={nutritionItem.key} style={styles.detailItem}>
|
||||
<View style={styles.nutritionInfo}>
|
||||
<Text style={styles.detailLabel}>{nutritionItem.name}</Text>
|
||||
<Text style={styles.detailValue}>{nutritionItem.value}</Text>
|
||||
</View>
|
||||
{nutritionItem.analysis && (
|
||||
<Text style={styles.analysisText}>{nutritionItem.analysis}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.metaInfo}>
|
||||
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.aiModel')}: {item.aiModel}</Text>
|
||||
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.provider')}: {item.aiProvider}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [expandedItems, toggleExpanded]);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="document-text-outline" size={64} color="#CCC" />
|
||||
<Text style={styles.emptyStateText}>{t('nutritionAnalysisHistory.empty.title')}</Text>
|
||||
<Text style={styles.emptyStateSubtext}>{t('nutritionAnalysisHistory.empty.subtitle')}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染错误状态
|
||||
const renderErrorState = () => (
|
||||
<View style={styles.errorState}>
|
||||
<Ionicons name="alert-circle-outline" size={64} color="#F44336" />
|
||||
<Text style={styles.errorStateText}>{t('nutritionAnalysisHistory.errors.loadFailed')}</Text>
|
||||
<Text style={styles.errorStateSubtext}>{error || t('nutritionAnalysisHistory.errors.unknownError')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
setLoading(true);
|
||||
fetchRecords(1, true);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t('nutritionAnalysisHistory.actions.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染底部加载指示器
|
||||
const renderFooter = () => {
|
||||
if (!loadingMore) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.loadingFooter}>
|
||||
<ActivityIndicator size="small" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingFooterText}>{t('nutritionAnalysisHistory.loadingMore')}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title={t('nutritionAnalysisHistory.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
|
||||
{/* 筛选按钮 */}
|
||||
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterButton, !statusFilter && styles.filterButtonActive]}
|
||||
onPress={() => {
|
||||
if (statusFilter !== '') {
|
||||
setStatusFilter('');
|
||||
setCurrentPage(1);
|
||||
// 直接调用数据获取,不依赖 useEffect
|
||||
setLoading(true);
|
||||
fetchRecords(1, true, '');
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
|
||||
{t('nutritionAnalysisHistory.filter.all')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterButton, statusFilter === 'success' && styles.filterButtonActive]}
|
||||
onPress={() => {
|
||||
if (statusFilter !== 'success') {
|
||||
setStatusFilter('success');
|
||||
setCurrentPage(1);
|
||||
// 直接调用数据获取,不依赖 useEffect
|
||||
setLoading(true);
|
||||
fetchRecords(1, true, 'success');
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
|
||||
{t('nutritionAnalysisHistory.status.success')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterButton, statusFilter === 'failed' && styles.filterButtonActive]}
|
||||
onPress={() => {
|
||||
if (statusFilter !== 'failed') {
|
||||
setStatusFilter('failed');
|
||||
setCurrentPage(1);
|
||||
// 直接调用数据获取,不依赖 useEffect
|
||||
setLoading(true);
|
||||
fetchRecords(1, true, 'failed');
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
|
||||
{t('nutritionAnalysisHistory.status.failed')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 记录列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>{t('nutritionAnalysisHistory.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={records}
|
||||
renderItem={renderRecordItem}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
colors={[Colors.light.primary]}
|
||||
tintColor={Colors.light.primary}
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.2} // 提高阈值,减少频繁触发
|
||||
ListEmptyComponent={error ? renderErrorState : renderEmptyState} // 错误时显示错误状态
|
||||
ListFooterComponent={renderFooter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewing
|
||||
images={previewImageUri ? [{ uri: previewImageUri }] : []}
|
||||
imageIndex={0}
|
||||
visible={showImagePreview}
|
||||
onRequestClose={() => setShowImagePreview(false)}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{dayjs().format(t('nutritionAnalysisHistory.dateFormat'))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5e5fbff',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
filterButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
},
|
||||
filterButtonActive: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderColor: Colors.light.primary,
|
||||
},
|
||||
filterButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#666',
|
||||
},
|
||||
filterButtonTextActive: {
|
||||
color: '#FFF',
|
||||
},
|
||||
listContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
recordItem: {
|
||||
backgroundColor: Colors.light.background,
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
recordHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
recordInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
recordTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
recordDate: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#FFF',
|
||||
},
|
||||
glassDeleteButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackDeleteButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(244, 67, 54, 0.3)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
},
|
||||
imageContainer: {
|
||||
marginBottom: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: 120,
|
||||
borderRadius: 12,
|
||||
},
|
||||
previewHint: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 16,
|
||||
padding: 6,
|
||||
},
|
||||
summaryContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
nutritionSummary: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
nutritionItem: {
|
||||
flex: 1,
|
||||
minWidth: '45%',
|
||||
backgroundColor: 'rgba(74, 144, 226, 0.1)',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
},
|
||||
nutritionLabel: {
|
||||
fontSize: 12,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
nutritionValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.primary,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 14,
|
||||
color: '#F44336',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
},
|
||||
expandButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
expandButtonText: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.primary,
|
||||
fontWeight: '500',
|
||||
marginRight: 4,
|
||||
},
|
||||
detailsContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
paddingTop: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
detailsTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
marginBottom: 12,
|
||||
},
|
||||
detailItem: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F8F8F8',
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.text,
|
||||
},
|
||||
nutritionInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.primary,
|
||||
},
|
||||
analysisText: {
|
||||
fontSize: 12,
|
||||
color: Colors.light.textSecondary,
|
||||
lineHeight: 16,
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: 'rgba(74, 144, 226, 0.05)',
|
||||
borderRadius: 6,
|
||||
},
|
||||
metaInfo: {
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F0F0F0',
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyStateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
marginTop: 16,
|
||||
},
|
||||
emptyStateSubtext: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginTop: 8,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.textSecondary,
|
||||
marginTop: 12,
|
||||
},
|
||||
loadingFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
loadingFooterText: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginLeft: 8,
|
||||
},
|
||||
errorState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
errorStateText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
marginTop: 16,
|
||||
},
|
||||
errorStateSubtext: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 20,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: Colors.light.primary,
|
||||
borderRadius: 24,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// ImageViewing 组件样式
|
||||
imageViewerHeader: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerHeaderText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
imageViewerFooter: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerFooterButton: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
imageViewerFooterButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
783
app/food/nutrition-label-analysis.tsx
Normal file
@@ -0,0 +1,783 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
analyzeNutritionImage,
|
||||
type NutritionAnalysisResponse
|
||||
} from '@/services/nutritionLabelAnalysis';
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
BackHandler,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function NutritionLabelAnalysisScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const router = useRouter();
|
||||
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
const { upload, uploading: uploadingToCos, progress: uploadProgress } = useCosUpload({
|
||||
prefix: 'nutrition-labels'
|
||||
});
|
||||
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [newAnalysisResult, setNewAnalysisResult] = useState<NutritionAnalysisResponse | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 流式请求相关引用
|
||||
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
// 处理Android返回键关闭图片预览
|
||||
React.useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showImagePreview) {
|
||||
setShowImagePreview(false);
|
||||
return true; // 阻止默认返回行为
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [showImagePreview]);
|
||||
|
||||
// 组件卸载时清理流式请求
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
try {
|
||||
if (streamAbortRef.current) {
|
||||
streamAbortRef.current.abort();
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[NUTRITION_ANALYSIS] Error aborting stream on unmount:', error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 请求相机权限
|
||||
const requestCameraPermission = async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert(t('nutritionLabelAnalysis.camera.permissionDenied'), t('nutritionLabelAnalysis.camera.permissionMessage'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 拍照
|
||||
const takePhoto = async () => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
triggerLightHaptic();
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
setNewAnalysisResult(null); // 清除之前的分析结果
|
||||
}
|
||||
};
|
||||
|
||||
// 从相册选择
|
||||
const pickImage = async () => {
|
||||
triggerLightHaptic();
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
setNewAnalysisResult(null); // 清除之前的分析结果
|
||||
}
|
||||
};
|
||||
|
||||
// 新的分析函数:先上传图片到COS,然后调用新API
|
||||
const startNewAnalysis = useCallback(async (uri: string) => {
|
||||
if (isAnalyzing || isUploading) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setNewAnalysisResult(null);
|
||||
|
||||
// 延迟滚动到分析结果区域,给UI一些时间更新
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollTo({ y: 350, animated: true });
|
||||
}, 300);
|
||||
|
||||
try {
|
||||
// 第一步:上传图片到COS
|
||||
console.log('[NUTRITION_ANALYSIS] 开始上传图片到COS...');
|
||||
const uploadResult = await upload(uri);
|
||||
console.log('[NUTRITION_ANALYSIS] 图片上传成功:', uploadResult.url);
|
||||
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(true);
|
||||
|
||||
// 第二步:调用新的营养成分分析API
|
||||
console.log('[NUTRITION_ANALYSIS] 开始调用营养成分分析API...');
|
||||
const analysisResponse = await analyzeNutritionImage({
|
||||
imageUrl: uploadResult.url
|
||||
});
|
||||
|
||||
console.log('[NUTRITION_ANALYSIS] API响应:', analysisResponse);
|
||||
|
||||
if (analysisResponse.success && analysisResponse.data) {
|
||||
// 直接使用服务端返回的数据,不做任何转换
|
||||
setNewAnalysisResult(analysisResponse);
|
||||
} else {
|
||||
throw new Error(analysisResponse.message || t('nutritionLabelAnalysis.errors.analysisFailed.defaultMessage'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
|
||||
// 显示错误提示
|
||||
Alert.alert(
|
||||
t('nutritionLabelAnalysis.errors.analysisFailed.title'),
|
||||
error.message || t('nutritionLabelAnalysis.errors.analysisFailed.message')
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [isAnalyzing, isUploading, upload]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#ffffffff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title={t('nutritionLabelAnalysis.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
right={
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.historyButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
style={[styles.historyButton, styles.fallbackBackground]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.scrollContainer}
|
||||
contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 图片区域 */}
|
||||
<View style={styles.imageContainer}>
|
||||
{imageUri ? (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowImagePreview(true)}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={styles.foodImage}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
{/* 预览提示图标 */}
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={20} color="#FFF" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 开始分析按钮 */}
|
||||
{!isAnalyzing && !isUploading && !newAnalysisResult && (
|
||||
<TouchableOpacity
|
||||
style={styles.analyzeButton}
|
||||
onPress={async () => {
|
||||
// 先验证登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (isLoggedIn) {
|
||||
startNewAnalysis(imageUri);
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="search-outline" size={20} color="#FFF" />
|
||||
<Text style={styles.analyzeButtonText}>{t('nutritionLabelAnalysis.actions.startAnalysis')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 删除图片按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.deleteImageButton}
|
||||
onPress={() => {
|
||||
setImageUri(null);
|
||||
setNewAnalysisResult(null);
|
||||
triggerLightHaptic();
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.placeholderContainer}>
|
||||
<View style={styles.placeholderContent}>
|
||||
<Ionicons name="document-text-outline" size={48} color="#666" />
|
||||
<Text style={styles.placeholderText}>{t('nutritionLabelAnalysis.placeholder.text')}</Text>
|
||||
</View>
|
||||
{/* 操作按钮区域 */}
|
||||
<View style={styles.imageActionButtonsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageActionButton}
|
||||
onPress={takePhoto}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
|
||||
<Text style={styles.imageActionButtonText}>{t('nutritionLabelAnalysis.actions.takePhoto')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
|
||||
onPress={pickImage}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="image-outline" size={20} color={Colors.light.primary} />
|
||||
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}>{t('nutritionLabelAnalysis.actions.selectFromAlbum')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
{/* 新API营养成分详细分析结果 */}
|
||||
{newAnalysisResult && newAnalysisResult.success && newAnalysisResult.data && (
|
||||
<View style={styles.analysisSection}>
|
||||
<View style={styles.analysisSectionHeader}>
|
||||
<View style={styles.analysisSectionHeaderIcon}>
|
||||
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
|
||||
</View>
|
||||
<Text style={styles.analysisSectionTitle}>{t('nutritionLabelAnalysis.results.title')}</Text>
|
||||
</View>
|
||||
<View style={styles.analysisCardsWrapper}>
|
||||
{newAnalysisResult.data.map((item, index) => (
|
||||
<View
|
||||
key={item.key || index}
|
||||
style={[
|
||||
styles.analysisCardItem,
|
||||
index === newAnalysisResult.data.length - 1 && styles.analysisCardItemLast
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#7F77FF', '#9B7CFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.analysisItemIconGradient}
|
||||
>
|
||||
<Ionicons name="nutrition-outline" size={24} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
<View style={styles.analysisItemContent}>
|
||||
<View style={styles.analysisItemHeader}>
|
||||
<Text style={styles.analysisItemName}>{item.name}</Text>
|
||||
<Text style={styles.analysisItemValue}>{item.value}</Text>
|
||||
</View>
|
||||
<View style={styles.analysisItemDescriptionRow}>
|
||||
<Ionicons
|
||||
name="information-circle-outline"
|
||||
size={16}
|
||||
color="rgba(107, 110, 214, 0.8)"
|
||||
style={styles.analysisItemDescriptionIcon}
|
||||
/>
|
||||
<Text style={styles.analysisItemDescription}>{item.analysis}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 上传状态 */}
|
||||
{isUploading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
{t('nutritionLabelAnalysis.status.uploading')} {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isAnalyzing && !newAnalysisResult && !isUploading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>{t('nutritionLabelAnalysis.status.analyzing')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewing
|
||||
images={imageUri ? [{ uri: imageUri }] : []}
|
||||
imageIndex={0}
|
||||
visible={showImagePreview}
|
||||
onRequestClose={() => setShowImagePreview(false)}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{dayjs().format(t('nutritionLabelAnalysis.imageViewer.dateFormat'))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5e5fbff',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
imageContainer: {
|
||||
position: 'relative',
|
||||
height: 300,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
foodImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 20,
|
||||
},
|
||||
previewHint: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
},
|
||||
deleteImageButton: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
backgroundColor: 'rgba(255, 59, 48, 0.9)',
|
||||
borderRadius: 20,
|
||||
padding: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
placeholderContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
placeholderContent: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 40,
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
marginTop: 8,
|
||||
},
|
||||
imageActionButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
imageActionButton: {
|
||||
flex: 1,
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
imageActionButtonSecondary: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderColor: Colors.light.primary,
|
||||
shadowOpacity: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
imageActionButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
resultCard: {
|
||||
backgroundColor: Colors.light.background,
|
||||
margin: 16,
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
},
|
||||
confidenceContainer: {
|
||||
backgroundColor: '#E8F5E8',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
confidenceText: {
|
||||
fontSize: 12,
|
||||
color: '#4CAF50',
|
||||
fontWeight: '500',
|
||||
},
|
||||
foodInfoContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
foodNameLabel: {
|
||||
fontSize: 14,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
foodNameValue: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
nutritionGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 8,
|
||||
},
|
||||
nutritionItem: {
|
||||
width: '50%',
|
||||
marginBottom: 12,
|
||||
paddingRight: 8,
|
||||
},
|
||||
nutritionLabel: {
|
||||
fontSize: 12,
|
||||
color: Colors.light.textSecondary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
nutritionValue: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.textSecondary,
|
||||
marginTop: 12,
|
||||
},
|
||||
// ImageViewing 组件样式
|
||||
imageViewerHeader: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerHeaderText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
},
|
||||
imageViewerFooter: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerFooterButton: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
imageViewerFooterButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 开始分析按钮样式
|
||||
analyzeButton: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
backgroundColor: Colors.light.primary,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
analyzeButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
// 营养成分详细分析卡片样式
|
||||
analysisSection: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 28,
|
||||
marginBottom: 20,
|
||||
},
|
||||
analysisSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 14,
|
||||
},
|
||||
analysisSectionHeaderIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(107, 110, 214, 0.12)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
analysisSectionTitle: {
|
||||
marginLeft: 10,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: Colors.light.text,
|
||||
},
|
||||
analysisCardsWrapper: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 28,
|
||||
padding: 18,
|
||||
shadowColor: 'rgba(107, 110, 214, 0.25)',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 10,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 6,
|
||||
},
|
||||
analysisCardItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(127, 119, 255, 0.1)',
|
||||
borderRadius: 22,
|
||||
shadowColor: 'rgba(127, 119, 255, 0.28)',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 6,
|
||||
},
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
analysisCardItemLast: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
analysisItemIconGradient: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14,
|
||||
},
|
||||
analysisItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
analysisItemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
analysisItemName: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#272753',
|
||||
},
|
||||
analysisItemValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.primary,
|
||||
},
|
||||
analysisItemDescriptionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
analysisItemDescriptionIcon: {
|
||||
marginRight: 6,
|
||||
marginTop: 2,
|
||||
},
|
||||
analysisItemDescription: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: 'rgba(39, 39, 83, 0.72)',
|
||||
},
|
||||
// 历史记录按钮样式
|
||||
historyButton: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 19,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackBackground: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
});
|
||||
687
app/gallery/index.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { AiReportRecord, generateAiReport, getAiReportHistory } from '@/services/aiReport';
|
||||
import { getAuthToken } from '@/services/api';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Share,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function GalleryScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { checkServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
|
||||
// 报告历史列表
|
||||
const [reports, setReports] = useState<AiReportRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
|
||||
const [reportImageUrl, setReportImageUrl] = useState<string | null>(null);
|
||||
const [reportLocalUri, setReportLocalUri] = useState<string | null>(null);
|
||||
const [reportModalVisible, setReportModalVisible] = useState(false);
|
||||
const [isSavingReport, setIsSavingReport] = useState(false);
|
||||
const [isSharingReport, setIsSharingReport] = useState(false);
|
||||
const reportSpinAnim = useRef(new Animated.Value(0)).current;
|
||||
const reportIconSpin = reportSpinAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg']
|
||||
});
|
||||
|
||||
const emptyImageHeight = useMemo(() => screenHeight / 1.5, [screenHeight]);
|
||||
|
||||
const todayString = useMemo(() => dayjs().format('YYYY-MM-DD'), []);
|
||||
|
||||
const reportImageSize = useMemo(() => {
|
||||
const maxWidth = Math.min(screenWidth - 40, 440);
|
||||
const maxHeight = screenHeight - 240;
|
||||
let width = maxWidth;
|
||||
let height = (maxWidth * 16) / 9;
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = (maxHeight * 9) / 16;
|
||||
}
|
||||
return { width, height };
|
||||
}, [screenHeight, screenWidth]);
|
||||
|
||||
// 加载报告历史
|
||||
const loadReports = useCallback(async (pageNum: number, refresh = false) => {
|
||||
try {
|
||||
const response = await getAiReportHistory({
|
||||
page: pageNum,
|
||||
pageSize: 10,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
if (refresh) {
|
||||
setReports(response.records);
|
||||
} else {
|
||||
setReports(prev => [...prev, ...response.records]);
|
||||
}
|
||||
setHasMore(pageNum < response.totalPages);
|
||||
setPage(pageNum);
|
||||
} catch (error: any) {
|
||||
console.error('load-ai-report-history-failed', error);
|
||||
if (refresh) {
|
||||
Toast.error(t('statistics.aiReport.loadFailed', '加载报告历史失败'));
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await loadReports(1, true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [loadReports]);
|
||||
|
||||
// 下拉刷新
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
await loadReports(1, true);
|
||||
setIsRefreshing(false);
|
||||
}, [loadReports]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
setIsLoadingMore(true);
|
||||
await loadReports(page + 1, false);
|
||||
setIsLoadingMore(false);
|
||||
}, [isLoadingMore, hasMore, page, loadReports]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGeneratingReport) {
|
||||
reportSpinAnim.stopAnimation();
|
||||
return;
|
||||
}
|
||||
reportSpinAnim.setValue(0);
|
||||
const loop = Animated.loop(
|
||||
Animated.timing(reportSpinAnim, {
|
||||
toValue: 1,
|
||||
duration: 1400,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [isGeneratingReport, reportSpinAnim]);
|
||||
|
||||
const handleGenerateReport = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok || isGeneratingReport) return;
|
||||
|
||||
// 检查 VIP 权限
|
||||
const access = checkServiceAccess();
|
||||
if (!access.canUseService) {
|
||||
openMembershipModal({
|
||||
onPurchaseSuccess: () => {
|
||||
// 购买成功后自动触发生成
|
||||
handleGenerateReport();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingReport(true);
|
||||
setReportLocalUri(null);
|
||||
Toast.info(t('statistics.aiReport.generating', '正在生成健康报告,预计 10~30 秒…'));
|
||||
try {
|
||||
const response = await generateAiReport({ date: todayString });
|
||||
const imageUrl = (response as any)?.imageUrl ?? (response as any)?.url ?? (response as any)?.image_url;
|
||||
if (!imageUrl) {
|
||||
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
|
||||
}
|
||||
setReportImageUrl(imageUrl);
|
||||
setReportModalVisible(true);
|
||||
Toast.success(t('statistics.aiReport.success', '报告已生成'));
|
||||
// 生成成功后刷新列表
|
||||
handleRefresh();
|
||||
} catch (error: any) {
|
||||
console.error('generate-ai-report-failed', error);
|
||||
Toast.error(error?.message ?? t('statistics.aiReport.failed', '生成报告失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsGeneratingReport(false);
|
||||
}
|
||||
}, [ensureLoggedIn, isGeneratingReport, checkServiceAccess, openMembershipModal, t, todayString, handleRefresh]);
|
||||
|
||||
const prepareLocalReportImage = useCallback(async () => {
|
||||
if (!reportImageUrl) {
|
||||
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
|
||||
}
|
||||
if (reportLocalUri) {
|
||||
return reportLocalUri;
|
||||
}
|
||||
const fileUri = `${FileSystem.cacheDirectory}ai-report-${Date.now()}.jpg`;
|
||||
const token = await getAuthToken();
|
||||
const download = await FileSystem.downloadAsync(
|
||||
reportImageUrl,
|
||||
fileUri,
|
||||
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
|
||||
);
|
||||
if (!download?.uri) {
|
||||
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
|
||||
}
|
||||
setReportLocalUri(download.uri);
|
||||
return download.uri;
|
||||
}, [reportImageUrl, reportLocalUri, t]);
|
||||
|
||||
const handleSaveReport = useCallback(async () => {
|
||||
if (isSavingReport) return;
|
||||
try {
|
||||
setIsSavingReport(true);
|
||||
const permission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Toast.warning(t('statistics.aiReport.permission', '需要相册权限才能保存图片'));
|
||||
return;
|
||||
}
|
||||
const localUri = await prepareLocalReportImage();
|
||||
await MediaLibrary.saveToLibraryAsync(localUri);
|
||||
Toast.success(t('statistics.aiReport.saved', '已保存到相册'));
|
||||
} catch (error: any) {
|
||||
console.error('save-ai-report-failed', error);
|
||||
Toast.error(error?.message ?? t('statistics.aiReport.saveFailed', '保存失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsSavingReport(false);
|
||||
}
|
||||
}, [isSavingReport, prepareLocalReportImage, t]);
|
||||
|
||||
const handleShareReport = useCallback(async () => {
|
||||
if (isSharingReport) return;
|
||||
try {
|
||||
setIsSharingReport(true);
|
||||
const localUri = await prepareLocalReportImage();
|
||||
await Share.share({
|
||||
message: t('statistics.aiReport.shareMessage', '这是我的 AI 健康报告,分享给你看看!'),
|
||||
url: Platform.OS === 'ios' ? localUri : `file://${localUri}`,
|
||||
title: t('statistics.aiReport.shareTitle', 'AI 健康报告')
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('share-ai-report-failed', error);
|
||||
Toast.error(error?.message ?? t('statistics.aiReport.shareFailed', '分享失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsSharingReport(false);
|
||||
}
|
||||
}, [isSharingReport, prepareLocalReportImage, t]);
|
||||
|
||||
// 点击卡片查看报告
|
||||
const handleCardPress = useCallback((report: AiReportRecord) => {
|
||||
if (!report.imageUrl) return;
|
||||
setReportImageUrl(report.imageUrl);
|
||||
setReportLocalUri(null);
|
||||
setReportModalVisible(true);
|
||||
}, []);
|
||||
|
||||
// 滚动到底部加载更多
|
||||
const handleScroll = useCallback((event: any) => {
|
||||
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
||||
const paddingToBottom = 100;
|
||||
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}, [handleLoadMore]);
|
||||
|
||||
const headerRight = isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleGenerateReport}
|
||||
disabled={isGeneratingReport}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.reportButton}
|
||||
glassEffectStyle="clear"
|
||||
isInteractive
|
||||
>
|
||||
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</Animated.View>
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleGenerateReport}
|
||||
style={[styles.reportButton, styles.reportButtonFallback]}
|
||||
disabled={isGeneratingReport}
|
||||
>
|
||||
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const headerTitle = (
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.aiReport.galleryTitle', 'AI 报告画廊')}</Text>
|
||||
<Text style={styles.headerSubtitle}>{t('statistics.aiReport.gallerySubtitle', '沉浸式浏览你的健康报告')}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<LinearGradient
|
||||
colors={['#f0f4ff', '#fdf8ff', '#f6f8fa']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<HeaderBar
|
||||
title={headerTitle}
|
||||
right={headerRight}
|
||||
tone="light"
|
||||
transparent
|
||||
/>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 56,
|
||||
paddingBottom: 40,
|
||||
paddingHorizontal: 16,
|
||||
...(reports.length === 0 && !isLoading ? { flexGrow: 1, justifyContent: 'center' } : {})
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#6B7280"
|
||||
/>
|
||||
}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
</View>
|
||||
) : reports.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Pressable
|
||||
style={styles.emptyImageCard}
|
||||
onPress={() => {
|
||||
const imageUrl = i18n.language?.startsWith('en')
|
||||
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
|
||||
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg';
|
||||
setReportImageUrl(imageUrl);
|
||||
setReportLocalUri(null);
|
||||
setReportModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{
|
||||
uri: i18n.language?.startsWith('en')
|
||||
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
|
||||
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg'
|
||||
}}
|
||||
style={[styles.emptyImage, { height: emptyImageHeight }]}
|
||||
contentFit="contain"
|
||||
transition={300}
|
||||
/>
|
||||
<View style={styles.emptyImageOverlay}>
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={14} color="#fff" />
|
||||
<Text style={styles.previewHintText}>{t('statistics.aiReport.clickToPreview', '点击预览模板')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyTitle}>{t('statistics.aiReport.emptyHistory', '暂无报告记录')}</Text>
|
||||
<Text style={styles.emptySubtitle}>{t('statistics.aiReport.emptyHistoryHint', '点击右上方按钮生成你的第一份报告')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.galleryGrid}>
|
||||
{reports.map((report) => (
|
||||
<Pressable
|
||||
key={report.id}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={() => handleCardPress(report)}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: report.imageUrl }}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
transition={250}
|
||||
/>
|
||||
<View style={styles.cardBody}>
|
||||
<Text numberOfLines={1} style={styles.cardTitle}>
|
||||
{dayjs(report.reportDate).format('YYYY年M月D日')}
|
||||
</Text>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{dayjs(report.createdAt).format('HH:mm')} {t('statistics.aiReport.generated', '生成')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<View style={styles.loadingMoreContainer}>
|
||||
<ActivityIndicator size="small" color="#6B7280" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{reportModalVisible && (
|
||||
<View style={styles.modalOverlay}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => setReportModalVisible(false)} />
|
||||
<View style={styles.modalCard}>
|
||||
{reportImageUrl ? (
|
||||
<ExpoImage
|
||||
source={{ uri: reportImageUrl }}
|
||||
style={[styles.reportImage, { width: reportImageSize.width, height: reportImageSize.height }]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.reportImageFallback, { width: reportImageSize.width, height: reportImageSize.height }]}>
|
||||
<Text style={styles.reportFallbackText}>{t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, isSavingReport && styles.modalButtonDisabled]}
|
||||
onPress={handleSaveReport}
|
||||
disabled={isSavingReport}
|
||||
>
|
||||
<Ionicons name="download-outline" size={18} color="#0F172A" />
|
||||
<Text style={styles.modalButtonText}>
|
||||
{isSavingReport ? t('statistics.aiReport.saving', '保存中…') : t('statistics.aiReport.save', '保存')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, isSharingReport && styles.modalButtonDisabled]}
|
||||
onPress={handleShareReport}
|
||||
disabled={isSharingReport}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={18} color="#0F172A" />
|
||||
<Text style={styles.modalButtonText}>
|
||||
{isSharingReport ? t('statistics.aiReport.sharing', '分享中…') : t('statistics.aiReport.share', '分享')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Pressable style={styles.closeRow} onPress={() => setReportModalVisible(false)}>
|
||||
<Ionicons name="close" size={18} color="#4B5563" />
|
||||
<Text style={styles.closeLabel}>{t('statistics.aiReport.close', '收起')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f7f8fb',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#0F172A',
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSubtitle: {
|
||||
marginTop: 2,
|
||||
color: '#6B7280',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
textAlign: 'center',
|
||||
},
|
||||
reportButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
reportButtonFallback: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
reportIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#E0F2FE',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
paddingTop: 100,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
},
|
||||
emptyImageCard: {
|
||||
width: '100%',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 6,
|
||||
},
|
||||
emptyImage: {
|
||||
width: '100%',
|
||||
height: 380,
|
||||
},
|
||||
emptyImageOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
borderRadius: 20,
|
||||
},
|
||||
previewHint: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
previewHintText: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
color: '#fff',
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#1F2937',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: '#3B82F6',
|
||||
borderRadius: 28,
|
||||
marginTop: 8,
|
||||
shadowColor: '#3B82F6',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 4,
|
||||
},
|
||||
emptyButtonText: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#fff',
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
galleryGrid: {
|
||||
gap: 18,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 22,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 6,
|
||||
},
|
||||
cardPressed: {
|
||||
transform: [{ scale: 0.99 }],
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: 360,
|
||||
},
|
||||
cardBody: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
gap: 4,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#111827',
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
modalOverlay: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(12, 18, 27, 0.78)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: '#FDFDFE',
|
||||
borderRadius: 20,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 18,
|
||||
elevation: 16,
|
||||
},
|
||||
reportImage: {
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
reportImageFallback: {
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
reportFallbackText: {
|
||||
textAlign: 'center',
|
||||
color: '#111827',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
modalButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#E0F2FE',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#BAE6FD',
|
||||
},
|
||||
modalButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
modalButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
closeRow: {
|
||||
marginTop: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
closeLabel: {
|
||||
fontSize: 14,
|
||||
color: '#4B5563',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
248
app/health-data-permissions.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type CardConfig = {
|
||||
key: string;
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
color: string;
|
||||
title: string;
|
||||
items: string[];
|
||||
};
|
||||
|
||||
export default function HealthDataPermissionsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const cards = useMemo<CardConfig[]>(() => ([
|
||||
{
|
||||
key: 'usage',
|
||||
icon: 'pulse-outline',
|
||||
color: '#34D399',
|
||||
title: t('healthPermissions.cards.usage.title'),
|
||||
items: t('healthPermissions.cards.usage.items', { returnObjects: true }) as string[],
|
||||
},
|
||||
{
|
||||
key: 'purpose',
|
||||
icon: 'bulb-outline',
|
||||
color: '#FBBF24',
|
||||
title: t('healthPermissions.cards.purpose.title'),
|
||||
items: t('healthPermissions.cards.purpose.items', { returnObjects: true }) as string[],
|
||||
},
|
||||
{
|
||||
key: 'control',
|
||||
icon: 'shield-checkmark-outline',
|
||||
color: '#60A5FA',
|
||||
title: t('healthPermissions.cards.control.title'),
|
||||
items: t('healthPermissions.cards.control.items', { returnObjects: true }) as string[],
|
||||
},
|
||||
{
|
||||
key: 'privacy',
|
||||
icon: 'lock-closed-outline',
|
||||
color: '#A78BFA',
|
||||
title: t('healthPermissions.cards.privacy.title'),
|
||||
items: t('healthPermissions.cards.privacy.items', { returnObjects: true }) as string[],
|
||||
},
|
||||
]), [t]);
|
||||
|
||||
const calloutItems = useMemo(() => (
|
||||
t('healthPermissions.callout.items', { returnObjects: true }) as string[]
|
||||
), [t]);
|
||||
|
||||
const contactDescription = t('healthPermissions.contact.description');
|
||||
const contactEmail = t('healthPermissions.contact.email');
|
||||
|
||||
const handleContactPress = () => {
|
||||
if (!contactEmail) return;
|
||||
void Linking.openURL(`mailto:${contactEmail}`);
|
||||
};
|
||||
|
||||
const contentTopPadding = insets.top + 72;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title={t('healthPermissions.title')}
|
||||
variant="elevated"
|
||||
transparent={true}
|
||||
/>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: contentTopPadding,
|
||||
paddingBottom: insets.bottom + 32,
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.heroCard}>
|
||||
<Text style={styles.heroTitle}>{t('healthPermissions.title')}</Text>
|
||||
<Text style={styles.heroSubtitle}>{t('healthPermissions.subtitle')}</Text>
|
||||
</View>
|
||||
|
||||
{cards.map((card) => (
|
||||
<View key={card.key} style={styles.infoCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.cardIcon, { backgroundColor: `${card.color}22` }]}>
|
||||
<Ionicons name={card.icon} size={20} color={card.color} />
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{card.title}</Text>
|
||||
</View>
|
||||
{card.items.map((item, index) => (
|
||||
<View key={`${card.key}-${index}`} style={styles.cardItemRow}>
|
||||
<View style={styles.bullet} />
|
||||
<Text style={styles.cardItemText}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.calloutCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[styles.cardIcon, { backgroundColor: '#F472B622' }]}>
|
||||
<Ionicons name="alert-circle-outline" size={20} color="#F472B6" />
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{t('healthPermissions.callout.title')}</Text>
|
||||
</View>
|
||||
{calloutItems.map((item, index) => (
|
||||
<View key={`callout-${index}`} style={styles.cardItemRow}>
|
||||
<View style={styles.bullet} />
|
||||
<Text style={styles.cardItemText}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.contactCard}>
|
||||
<Text style={styles.contactTitle}>{t('healthPermissions.contact.title')}</Text>
|
||||
<Text style={styles.contactDescription}>{contactDescription}</Text>
|
||||
{contactEmail ? (
|
||||
<TouchableOpacity style={styles.contactButton} onPress={handleContactPress} activeOpacity={0.85}>
|
||||
<Ionicons name="mail-outline" size={18} color="#fff" />
|
||||
<Text style={styles.contactButtonText}>{contactEmail}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
heroCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 2,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 12,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 16,
|
||||
color: '#4B5563',
|
||||
lineHeight: 22,
|
||||
},
|
||||
infoCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 18,
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F3F4F6',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 10,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
},
|
||||
cardItemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
bullet: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#9370DB',
|
||||
marginTop: 8,
|
||||
marginRight: 10,
|
||||
},
|
||||
cardItemText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
lineHeight: 20,
|
||||
},
|
||||
calloutCard: {
|
||||
backgroundColor: '#FEF3F2',
|
||||
borderRadius: 18,
|
||||
padding: 18,
|
||||
marginBottom: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FECACA',
|
||||
},
|
||||
contactCard: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 18,
|
||||
padding: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F3F4F6',
|
||||
},
|
||||
contactTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
marginBottom: 8,
|
||||
},
|
||||
contactDescription: {
|
||||
fontSize: 14,
|
||||
color: '#4B5563',
|
||||
lineHeight: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
contactButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#111827',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
},
|
||||
contactButtonText: {
|
||||
marginLeft: 8,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||