Compare commits
158 Commits
e56ebe3636
...
feature/he
| Author | SHA1 | Date | |
|---|---|---|---|
| 409f125db1 | |||
|
|
eef0134ddc | ||
|
|
0013dc3266 | ||
|
|
37a0687456 | ||
|
|
74b49efe23 | ||
|
|
3d08721474 | ||
|
|
f3d4264b53 | ||
|
|
a254af92c7 | ||
|
|
e713ffbace | ||
|
|
02b2de3ea3 | ||
|
|
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 |
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 文档**: 接口文档
|
||||
- **组件文档**: 组件说明
|
||||
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
},
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
"kiroAgent.configureMCP": "Enabled",
|
||||
"codingcopilot.enableCompletionLanguage": {}
|
||||
}
|
||||
|
||||
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.
|
||||
142
CLAUDE.md
@@ -3,42 +3,122 @@
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
- **Run on iOS**: `npm run ios`
|
||||
|
||||
### 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 Prebuild/Ejected) with TypeScript using Expo Router for file-based navigation
|
||||
- **State Management**: Redux Toolkit with domain-specific slices (`store/`) and typed hooks (`hooks/redux.ts`)
|
||||
- **Authentication**: Custom auth guard system with `useAuthGuard` hook for protected navigation
|
||||
- **Navigation**:
|
||||
- File-based routing in `app/` directory with nested layouts
|
||||
- Tab-based navigation with custom styling and haptic feedback
|
||||
- Route constants defined in `constants/Routes.ts`, every page should use Routes define and jump
|
||||
- **UI System**:
|
||||
- Themed components (`ThemedText`, `ThemedView`) with color scheme support
|
||||
- Custom icon system with `IconSymbol` component for iOS symbols
|
||||
- Reusable UI components in `components/ui/`
|
||||
- UI Colors in `constants/Colors.ts`
|
||||
- **Data Layer**:
|
||||
- API services in `services/` directory with centralized API client
|
||||
- AsyncStorage for local persistence
|
||||
- Background task management for sync operations
|
||||
- **Native Integration**:
|
||||
- Health data integration with HealthKit
|
||||
- Apple Authentication
|
||||
- Camera and photo library access for posture assessment
|
||||
- Push notifications with background task support
|
||||
- Haptic feedback integration
|
||||
|
||||
### 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/`** - 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 automatically syncs checkin data changes to backend
|
||||
- **Type-safe Navigation**: Uses Expo Router with TypeScript for route type safety
|
||||
|
||||
- **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 support and color tokens
|
||||
- **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
|
||||
- Use absolute imports with `@/` prefix for all internal imports
|
||||
- Follow existing Redux slice patterns for state management
|
||||
- Implement auth guards using `useAuthGuard` hook for protected features
|
||||
- Use themed components for consistent styling
|
||||
- Follow established navigation patterns with typed routes
|
||||
|
||||
### 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
|
||||
- 总是先总结方案,等我确认之后,再进行实现
|
||||
16
android/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
182
android/app/build.gradle
Normal file
@@ -0,0 +1,182 @@
|
||||
apply plugin: "com.android.application"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
apply plugin: "com.facebook.react"
|
||||
|
||||
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
|
||||
/**
|
||||
* This is the configuration block to customize your React Native Android app.
|
||||
* By default you don't need to apply any configuration, just uncomment the lines you need.
|
||||
*/
|
||||
react {
|
||||
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
|
||||
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
|
||||
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
// Use Expo CLI to bundle the app, this ensures the Metro config
|
||||
// works correctly with Expo projects.
|
||||
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
|
||||
bundleCommand = "export:embed"
|
||||
|
||||
/* Folders */
|
||||
// The root of your project, i.e. where "package.json" lives. Default is '../..'
|
||||
// root = file("../../")
|
||||
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
|
||||
// reactNativeDir = file("../../node_modules/react-native")
|
||||
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
|
||||
// codegenDir = file("../../node_modules/@react-native/codegen")
|
||||
|
||||
/* Variants */
|
||||
// The list of variants to that are debuggable. For those we're going to
|
||||
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
|
||||
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
|
||||
// debuggableVariants = ["liteDebug", "prodDebug"]
|
||||
|
||||
/* Bundling */
|
||||
// A list containing the node command and its flags. Default is just 'node'.
|
||||
// nodeExecutableAndArgs = ["node"]
|
||||
|
||||
//
|
||||
// The path to the CLI configuration file. Default is empty.
|
||||
// bundleConfig = file(../rn-cli.config.js)
|
||||
//
|
||||
// The name of the generated asset file containing your JS bundle
|
||||
// bundleAssetName = "MyApplication.android.bundle"
|
||||
//
|
||||
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
|
||||
// entryFile = file("../js/MyApplication.android.js")
|
||||
//
|
||||
// A list of extra flags to pass to the 'bundle' commands.
|
||||
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
|
||||
// extraPackagerArgs = []
|
||||
|
||||
/* Hermes Commands */
|
||||
// The hermes compiler command to run. By default it is 'hermesc'
|
||||
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
|
||||
//
|
||||
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
|
||||
// hermesFlags = ["-O", "-output-source-map"]
|
||||
|
||||
/* Autolinking */
|
||||
autolinkLibrariesWithApp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore (JSC)
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
|
||||
|
||||
android {
|
||||
ndkVersion rootProject.ext.ndkVersion
|
||||
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
namespace 'com.anonymous.digitalpilates'
|
||||
defaultConfig {
|
||||
applicationId 'com.anonymous.digitalpilates'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0.12"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug
|
||||
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
|
||||
shrinkResources enableShrinkResources.toBoolean()
|
||||
minifyEnabled enableMinifyInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
|
||||
crunchPngs enablePngCrunchInRelease.toBoolean()
|
||||
}
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
|
||||
useLegacyPackaging enableLegacyPackaging.toBoolean()
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
// Accepts values in comma delimited lists, example:
|
||||
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
|
||||
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
|
||||
// Split option: 'foo,bar' -> ['foo', 'bar']
|
||||
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
|
||||
// Trim all elements in place.
|
||||
for (i in 0..<options.size()) options[i] = options[i].trim();
|
||||
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
|
||||
options -= ""
|
||||
|
||||
if (options.length > 0) {
|
||||
println "android.packagingOptions.$prop += $options ($options.length)"
|
||||
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
|
||||
options.each {
|
||||
android.packagingOptions[prop] += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
|
||||
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
|
||||
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
|
||||
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
|
||||
|
||||
if (isGifEnabled) {
|
||||
// For animated gif support
|
||||
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
|
||||
if (isWebpEnabled) {
|
||||
// For webp support
|
||||
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
|
||||
if (isWebpAnimatedEnabled) {
|
||||
// Animated webp support
|
||||
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
BIN
android/app/debug.keystore
Normal file
14
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# By default, the flags in this file are appended to flags specified
|
||||
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
|
||||
# You can edit the include path and order by changing the proguardFiles
|
||||
# directive in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# react-native-reanimated
|
||||
-keep class com.swmansion.reanimated.** { *; }
|
||||
-keep class com.facebook.react.turbomodule.** { *; }
|
||||
|
||||
# Add any project specific keep options here:
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
|
||||
</manifest>
|
||||
37
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false">
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
|
||||
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
|
||||
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
|
||||
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
|
||||
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="digitalpilates"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.anonymous.digitalpilates
|
||||
import expo.modules.splashscreen.SplashScreenManager
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
import com.facebook.react.ReactActivity
|
||||
import com.facebook.react.ReactActivityDelegate
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||
|
||||
import expo.modules.ReactActivityDelegateWrapper
|
||||
|
||||
class MainActivity : ReactActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
// setTheme(R.style.AppTheme);
|
||||
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
|
||||
SplashScreenManager.registerOnActivity(this)
|
||||
// @generated end expo-splashscreen
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the main component registered from JavaScript. This is used to schedule
|
||||
* rendering of the component.
|
||||
*/
|
||||
override fun getMainComponentName(): String = "main"
|
||||
|
||||
/**
|
||||
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
|
||||
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
|
||||
*/
|
||||
override fun createReactActivityDelegate(): ReactActivityDelegate {
|
||||
return ReactActivityDelegateWrapper(
|
||||
this,
|
||||
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
|
||||
object : DefaultReactActivityDelegate(
|
||||
this,
|
||||
mainComponentName,
|
||||
fabricEnabled
|
||||
){})
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the back button behavior with Android S
|
||||
* where moving root activities to background instead of finishing activities.
|
||||
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
|
||||
*/
|
||||
override fun invokeDefaultOnBackPressed() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
if (!moveTaskToBack(false)) {
|
||||
// For non-root activities, use the default implementation to finish them.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the default back button implementation on Android S
|
||||
// because it's doing more than [Activity.moveTaskToBack] in fact.
|
||||
super.invokeDefaultOnBackPressed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.anonymous.digitalpilates
|
||||
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||
import com.facebook.react.defaults.DefaultReactNativeHost
|
||||
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||
|
||||
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
|
||||
|
||||
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
||||
}
|
||||
)
|
||||
|
||||
override val reactHost: ReactHost
|
||||
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DefaultNewArchitectureEntryPoint.releaseLevel = try {
|
||||
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
ReleaseLevel.STABLE
|
||||
}
|
||||
loadReactNative(this)
|
||||
ApplicationLifecycleDispatcher.onApplicationCreate(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-hdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/drawable-mdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 812 B |
BIN
android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/notification_icon.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,6 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splashscreen_background"/>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
37
android/app/src/main/res/drawable/rn_edit_text_material.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (C) 2014 The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
||||
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
||||
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
||||
>
|
||||
|
||||
<selector>
|
||||
<!--
|
||||
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
||||
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
||||
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
||||
|
||||
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
|
||||
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
||||
-->
|
||||
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
||||
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
||||
</selector>
|
||||
|
||||
</inset>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/drink_water_foreground.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/drink_water_foreground.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/drink_water_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 39 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/drink_water.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 65 KiB |
1
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1 @@
|
||||
<resources/>
|
||||
7
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<color name="splashscreen_background">#ffffff</color>
|
||||
<color name="iconBackground">#ffffff</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#ffffff</color>
|
||||
<color name="notification_icon_color">#ffffff</color>
|
||||
</resources>
|
||||
6
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Out Live</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>
|
||||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
</resources>
|
||||
14
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
|
||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">#ffffff</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||
</style>
|
||||
</resources>
|
||||
24
android/build.gradle
Normal file
@@ -0,0 +1,24 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
65
android/gradle.properties
Normal file
@@ -0,0 +1,65 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
|
||||
# Enable AAPT2 PNG crunching
|
||||
android.enablePngCrunchInReleaseBuilds=true
|
||||
|
||||
# Use this property to specify which architecture you want to build.
|
||||
# You can also override it from the CLI using
|
||||
# ./gradlew <task> -PreactNativeArchitectures=x86_64
|
||||
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
|
||||
|
||||
# Use this property to enable support to the new architecture.
|
||||
# This will allow you to use TurboModules and the Fabric render in
|
||||
# your application. You should enable this flag either if you want
|
||||
# to write custom TurboModules/Fabric components OR use libraries that
|
||||
# are providing them.
|
||||
newArchEnabled=true
|
||||
|
||||
# Use this property to enable or disable the Hermes JS engine.
|
||||
# If set to false, you will be using JSC instead.
|
||||
hermesEnabled=false
|
||||
|
||||
# Use this property to enable edge-to-edge display support.
|
||||
# This allows your app to draw behind system bars for an immersive UI.
|
||||
# Note: Only works with ReactActivity and should not be used with custom Activity.
|
||||
edgeToEdgeEnabled=true
|
||||
|
||||
# Enable GIF support in React Native images (~200 B increase)
|
||||
expo.gif.enabled=true
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
expo.webp.enabled=true
|
||||
# Enable animated webp support (~3.4 MB increase)
|
||||
# Disabled by default because iOS doesn't support animated webp
|
||||
expo.webp.animated=false
|
||||
|
||||
# Enable network inspector
|
||||
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||
|
||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||
expo.useLegacyPackaging=false
|
||||
|
||||
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
|
||||
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
|
||||
expo.edgeToEdgeEnabled=true
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
39
android/settings.gradle
Normal file
@@ -0,0 +1,39 @@
|
||||
pluginManagement {
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
|
||||
}.standardOutput.asText.get().trim()
|
||||
).getParentFile().absolutePath
|
||||
includeBuild(reactNativeGradlePlugin)
|
||||
|
||||
def expoPluginsPath = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
|
||||
}.standardOutput.asText.get().trim(),
|
||||
"../android/expo-gradle-plugin"
|
||||
).absolutePath
|
||||
includeBuild(expoPluginsPath)
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
|
||||
ex.autolinkLibrariesFromCommand()
|
||||
} else {
|
||||
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||
}
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = 'Out Live'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
65
app.json
@@ -2,74 +2,55 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.4",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/Sealife.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": "应用需要写入相册以保存拍摄的体态照片(可选)。",
|
||||
"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/Sealife.jpeg",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.anonymous.digitalpilates",
|
||||
"permissions": [
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.WAKE_LOCK"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/Sealife.jpeg"
|
||||
"appleTeamId": "756WVXJ6MT"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-updates",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/Sealife.jpeg",
|
||||
"image": "./assets/logo.png",
|
||||
"imageWidth": 40,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-health",
|
||||
{
|
||||
"enableHealthAPI": true,
|
||||
"healthSharePermission": "应用需要访问您的健康数据(步数、能量消耗、心率变异性等)以展示运动统计和压力分析。",
|
||||
"healthUpdatePermission": "应用需要更新您的健康数据(体重信息)以记录您的健身进度。"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/images/Sealife.jpeg",
|
||||
"color": "#ffffff",
|
||||
"sounds": [
|
||||
"./assets/sounds/notification.wav"
|
||||
]
|
||||
"icon": "./assets/logo.png",
|
||||
"color": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -84,14 +65,22 @@
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-task",
|
||||
{
|
||||
"minimumInterval": 15
|
||||
}
|
||||
"expo-background-task"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"android": {
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
},
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkAutomatically": "ON_LOAD",
|
||||
"url": "https://pilate.richarjiang.com/api/expo-updates/manifest"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
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, ViewStyle } from 'react-native';
|
||||
|
||||
@@ -8,32 +12,42 @@ 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;
|
||||
title: string;
|
||||
titleKey: string;
|
||||
};
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', title: '健康' },
|
||||
// explore: { icon: 'magnifyingglass.circle.fill', title: '发现' },
|
||||
goals: { icon: 'flag.fill', title: '习惯' },
|
||||
personal: { icon: 'person.fill', title: '个人' },
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'health.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();
|
||||
|
||||
// 获取已启用的标签配置(按自定义顺序)
|
||||
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
explore: ROUTES.TAB_EXPLORE,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
statistics: ROUTES.TAB_STATISTICS,
|
||||
medications: ROUTES.TAB_MEDICATIONS,
|
||||
fasting: ROUTES.TAB_FASTING,
|
||||
challenges: ROUTES.TAB_CHALLENGES,
|
||||
personal: ROUTES.TAB_PERSONAL,
|
||||
};
|
||||
|
||||
return routeMap[routeName] === pathname || pathname.includes(routeName);
|
||||
@@ -59,16 +73,17 @@ export default function TabLayout() {
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
activeOpacity={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 6,
|
||||
marginHorizontal: 2,
|
||||
marginVertical: 10,
|
||||
borderRadius: 25,
|
||||
backgroundColor: isSelected ? colorTokens.tabBarActiveBackground : 'transparent',
|
||||
paddingHorizontal: isSelected ? 16 : 10,
|
||||
paddingHorizontal: isSelected ? 8 : 4,
|
||||
paddingVertical: 8,
|
||||
}}
|
||||
>
|
||||
@@ -84,11 +99,11 @@ export default function TabLayout() {
|
||||
color: colorTokens.tabIconSelected,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
marginLeft: 6
|
||||
}}
|
||||
numberOfLines={0 as any}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tabConfig.title}
|
||||
{t(tabConfig.titleKey)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -96,29 +111,62 @@ export default function TabLayout() {
|
||||
);
|
||||
};
|
||||
|
||||
// Custom tab bar background component
|
||||
const TabBarBackground = () => {
|
||||
if (glassEffectAvailable) {
|
||||
return (
|
||||
<GlassContainer
|
||||
spacing={8}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 34,
|
||||
}}
|
||||
>
|
||||
<GlassView
|
||||
isInteractive
|
||||
glassEffectStyle="regular"
|
||||
tintColor={theme === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(255,255,255,0.3)'}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 34,
|
||||
}}
|
||||
/>
|
||||
</GlassContainer>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Common screen options
|
||||
const getScreenOptions = (routeName: string): BottomTabNavigationOptions => ({
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colorTokens.tabIconSelected,
|
||||
tabBarButton: createTabButton(routeName),
|
||||
tabBarBackground: TabBarBackground,
|
||||
tabBarStyle: {
|
||||
position: 'absolute',
|
||||
bottom: TAB_BAR_BOTTOM_OFFSET,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
borderRadius: 34,
|
||||
backgroundColor: colorTokens.tabBarBackground,
|
||||
backgroundColor: glassEffectAvailable ? 'transparent' : colorTokens.tabBarBackground,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowOpacity: glassEffectAvailable ? 0.1 : 0.2,
|
||||
shadowRadius: 10,
|
||||
elevation: 5,
|
||||
paddingHorizontal: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginHorizontal: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
marginHorizontal: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
alignSelf: 'center',
|
||||
borderWidth: glassEffectAvailable ? 1 : 0,
|
||||
borderColor: glassEffectAvailable ? (theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') : 'transparent',
|
||||
} as ViewStyle,
|
||||
tabBarItemStyle: {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -131,17 +179,45 @@ export default function TabLayout() {
|
||||
tabBarShowLabel: false,
|
||||
});
|
||||
|
||||
// 根据配置渲染标签页
|
||||
if (glassEffectAvailable) {
|
||||
return (
|
||||
<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="statistics"
|
||||
initialRouteName={initialRouteName}
|
||||
screenOptions={({ route }) => getScreenOptions(route.name)}
|
||||
>
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||
<Tabs.Screen name="explore" options={{ title: '发现', href: null }} />
|
||||
<Tabs.Screen name="coach" options={{ title: 'AI', href: null }} />
|
||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||
{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',
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,785 +0,0 @@
|
||||
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||||
import GoalTemplateModal from '@/components/GoalTemplateModal';
|
||||
import { GoalsPageGuide } from '@/components/GoalsPageGuide';
|
||||
import { GuideTestButton } from '@/components/GuideTestButton';
|
||||
import { TaskCard } from '@/components/TaskCard';
|
||||
import { TaskFilterTabs, TaskFilterType } from '@/components/TaskFilterTabs';
|
||||
import { TaskProgressCard } from '@/components/TaskProgressCard';
|
||||
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
|
||||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
import { GoalTemplate } from '@/constants/goalTemplates';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { clearErrors, createGoal } from '@/store/goalsSlice';
|
||||
import { clearErrors as clearTaskErrors, fetchTasks, loadMoreTasks } from '@/store/tasksSlice';
|
||||
import { CreateGoalRequest, TaskListItem } from '@/types/goals';
|
||||
import { checkGuideCompleted, markGuideCompleted } from '@/utils/guideHelpers';
|
||||
import { GoalNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Lottie from 'lottie-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, FlatList, Image, RefreshControl, SafeAreaView, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
const { showConfirm } = useGlobalDialog();
|
||||
|
||||
// Redux状态
|
||||
const {
|
||||
tasks,
|
||||
tasksLoading,
|
||||
tasksError,
|
||||
tasksPagination,
|
||||
completeError,
|
||||
skipError,
|
||||
} = useAppSelector((state) => state.tasks);
|
||||
|
||||
const {
|
||||
createLoading,
|
||||
createError
|
||||
} = useAppSelector((state) => state.goals);
|
||||
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
|
||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
|
||||
const [modalKey, setModalKey] = useState(0); // 用于强制重新渲染弹窗
|
||||
const [showGuide, setShowGuide] = useState(false); // 控制引导显示
|
||||
const [selectedTemplateData, setSelectedTemplateData] = useState<Partial<CreateGoalRequest> | undefined>();
|
||||
|
||||
// 庆祝动画引用
|
||||
const celebrationAnimationRef = useRef<CelebrationAnimationRef>(null);
|
||||
|
||||
// 页面聚焦时重新加载数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('useFocusEffect - loading tasks');
|
||||
|
||||
if (isLoggedIn) {
|
||||
loadTasks();
|
||||
checkAndShowGuide();
|
||||
}
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
// 检查并显示用户引导
|
||||
const checkAndShowGuide = async () => {
|
||||
try {
|
||||
const hasCompletedGuide = await checkGuideCompleted('GOALS_PAGE');
|
||||
if (!hasCompletedGuide) {
|
||||
// 延迟显示引导,确保页面完全加载
|
||||
setTimeout(() => {
|
||||
setShowGuide(true);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查引导状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
await dispatch(fetchTasks({
|
||||
startDate: dayjs().startOf('day').toISOString(),
|
||||
endDate: dayjs().endOf('day').toISOString(),
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadTasks();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多任务
|
||||
const handleLoadMoreTasks = async () => {
|
||||
if (tasksPagination.hasMore && !tasksLoading) {
|
||||
try {
|
||||
await dispatch(loadMoreTasks()).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to load more tasks:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理错误提示
|
||||
useEffect(() => {
|
||||
|
||||
if (tasksError) {
|
||||
Alert.alert('错误', tasksError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
if (createError) {
|
||||
Alert.alert('创建失败', createError);
|
||||
dispatch(clearErrors());
|
||||
}
|
||||
if (completeError) {
|
||||
Alert.alert('完成失败', completeError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
if (skipError) {
|
||||
Alert.alert('跳过失败', skipError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
}, [tasksError, createError, completeError, skipError, dispatch]);
|
||||
|
||||
// 重置弹窗表单数据
|
||||
const handleModalSuccess = () => {
|
||||
// 不需要在这里改变 modalKey,因为弹窗已经关闭了
|
||||
// 下次打开时会自动使用新的 modalKey
|
||||
setSelectedTemplateData(undefined);
|
||||
};
|
||||
|
||||
// 处理模板选择
|
||||
const handleSelectTemplate = (template: GoalTemplate) => {
|
||||
setSelectedTemplateData(template.data);
|
||||
setShowTemplateModal(false);
|
||||
setModalKey(prev => prev + 1);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// 处理创建自定义目标
|
||||
const handleCreateCustomGoal = () => {
|
||||
setSelectedTemplateData(undefined);
|
||||
setShowTemplateModal(false);
|
||||
setModalKey(prev => prev + 1);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// 打开模板选择弹窗
|
||||
const handleOpenTemplateModal = () => {
|
||||
setSelectedTemplateData(undefined);
|
||||
setShowTemplateModal(true);
|
||||
};
|
||||
|
||||
// 创建目标处理函数
|
||||
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
|
||||
try {
|
||||
await dispatch(createGoal(goalData)).unwrap();
|
||||
setShowCreateModal(false);
|
||||
|
||||
// 获取用户名
|
||||
const userName = userProfile?.name || '主人';
|
||||
|
||||
// 创建目标成功后,设置定时推送
|
||||
try {
|
||||
if (goalData.hasReminder) {
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: goalData.title,
|
||||
repeatType: goalData.repeatType,
|
||||
frequency: goalData.frequency,
|
||||
hasReminder: goalData.hasReminder,
|
||||
reminderTime: goalData.reminderTime,
|
||||
customRepeatRule: goalData.customRepeatRule,
|
||||
startTime: goalData.startTime,
|
||||
},
|
||||
userName
|
||||
);
|
||||
console.log(`目标"${goalData.title}"的定时推送已创建,通知ID:`, notificationIds);
|
||||
}
|
||||
|
||||
} catch (notificationError) {
|
||||
console.error('创建目标定时推送失败:', notificationError);
|
||||
// 通知创建失败不影响目标创建的成功
|
||||
}
|
||||
|
||||
// 使用确认弹窗显示成功消息
|
||||
showConfirm(
|
||||
{
|
||||
title: '目标创建成功',
|
||||
message: '恭喜!您的目标已成功创建。系统将自动生成相应的任务,帮助您实现目标。',
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
icon: 'checkmark-circle',
|
||||
iconColor: '#10B981',
|
||||
},
|
||||
() => {
|
||||
// 用户点击确定后的回调
|
||||
console.log('用户确认了目标创建成功');
|
||||
}
|
||||
);
|
||||
|
||||
// 创建目标后重新加载任务列表
|
||||
loadTasks();
|
||||
} catch (error) {
|
||||
// 错误已在useEffect中处理
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 导航到任务列表页面
|
||||
const handleNavigateToTasks = () => {
|
||||
pushIfAuthedElseLogin('/task-list');
|
||||
};
|
||||
|
||||
// 计算各状态的任务数量
|
||||
const taskCounts = {
|
||||
all: tasks.length,
|
||||
pending: tasks.filter(task => task.status === 'pending').length,
|
||||
completed: tasks.filter(task => task.status === 'completed').length,
|
||||
skipped: tasks.filter(task => task.status === 'skipped').length,
|
||||
};
|
||||
|
||||
// 根据筛选条件过滤任务,并将已完成的任务放到最后
|
||||
const filteredTasks = React.useMemo(() => {
|
||||
let filtered: TaskListItem[] = [];
|
||||
|
||||
switch (selectedFilter) {
|
||||
case 'pending':
|
||||
filtered = tasks.filter(task => task.status === 'pending');
|
||||
break;
|
||||
case 'completed':
|
||||
filtered = tasks.filter(task => task.status === 'completed');
|
||||
break;
|
||||
case 'skipped':
|
||||
filtered = tasks.filter(task => task.status === 'skipped');
|
||||
break;
|
||||
default:
|
||||
filtered = tasks;
|
||||
break;
|
||||
}
|
||||
|
||||
// 对所有筛选结果进行排序:已完成的任务放到最后
|
||||
return [...filtered].sort((a, b) => {
|
||||
// 如果a已完成而b未完成,a排在后面
|
||||
if (a.status === 'completed' && b.status !== 'completed') {
|
||||
return 1;
|
||||
}
|
||||
// 如果b已完成而a未完成,b排在后面
|
||||
if (b.status === 'completed' && a.status !== 'completed') {
|
||||
return -1;
|
||||
}
|
||||
// 如果都已完成或都未完成,保持原有顺序
|
||||
return 0;
|
||||
});
|
||||
}, [tasks, selectedFilter]);
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = (filter: TaskFilterType) => {
|
||||
setSelectedFilter(filter);
|
||||
};
|
||||
|
||||
// 处理引导完成
|
||||
const handleGuideComplete = async () => {
|
||||
try {
|
||||
await markGuideCompleted('GOALS_PAGE');
|
||||
setShowGuide(false);
|
||||
} catch (error) {
|
||||
console.error('保存引导状态失败:', error);
|
||||
setShowGuide(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理任务完成
|
||||
const handleTaskCompleted = (completedTask: TaskListItem) => {
|
||||
// 触发震动反馈
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
|
||||
// 播放庆祝动画
|
||||
celebrationAnimationRef.current?.play();
|
||||
|
||||
console.log(`任务 "${completedTask.title}" 已完成,播放庆祝动画`);
|
||||
};
|
||||
|
||||
// 渲染任务项
|
||||
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||
<TaskCard
|
||||
task={item}
|
||||
onTaskCompleted={handleTaskCompleted}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
let title = '暂无任务';
|
||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||
|
||||
if (selectedFilter === 'pending') {
|
||||
title = '暂无待完成的任务';
|
||||
subtitle = '当前没有待完成的任务';
|
||||
} else if (selectedFilter === 'completed') {
|
||||
title = '暂无已完成的任务';
|
||||
subtitle = '完成一些任务后,它们会显示在这里';
|
||||
} else if (selectedFilter === 'skipped') {
|
||||
title = '暂无已跳过的任务';
|
||||
subtitle = '跳过一些任务后,它们会显示在这里';
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/ImageEmpty.png')}
|
||||
style={styles.emptyStateImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染加载更多
|
||||
const renderLoadMore = () => {
|
||||
if (!tasksPagination.hasMore) return null;
|
||||
return (
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||||
{tasksLoading ? '加载中...' : '上拉加载更多'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#F0F9FF', '#E0F2FE']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#7A5AF8',
|
||||
height: 233,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
}}>
|
||||
{/* 右下角Lottie动画 */}
|
||||
<Lottie
|
||||
source={require('@/assets/lottie/Goal.json')}
|
||||
style={styles.bottomRightImage}
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
|
||||
习惯养成
|
||||
</Text>
|
||||
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
|
||||
自律让我更健康
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 任务进度卡片 */}
|
||||
<View >
|
||||
<TaskProgressCard
|
||||
tasks={tasks}
|
||||
headerButtons={
|
||||
<View style={styles.cardHeaderButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
|
||||
onPress={handleNavigateToTasks}
|
||||
>
|
||||
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
|
||||
历史
|
||||
</Text>
|
||||
<MaterialIcons name="list" size={16} color={colorTokens.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={handleOpenTemplateModal}
|
||||
>
|
||||
<Text style={styles.cardAddButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 任务筛选标签 */}
|
||||
<TaskFilterTabs
|
||||
selectedFilter={selectedFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
taskCounts={taskCounts}
|
||||
/>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<View style={styles.taskListContainer}>
|
||||
<FlatList
|
||||
data={filteredTasks}
|
||||
renderItem={renderTaskItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.taskList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#0EA5E9']}
|
||||
tintColor="#0EA5E9"
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMoreTasks}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderLoadMore}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 目标模板选择弹窗 */}
|
||||
<GoalTemplateModal
|
||||
visible={showTemplateModal}
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onCreateCustom={handleCreateCustomGoal}
|
||||
/>
|
||||
|
||||
{/* 创建目标弹窗 */}
|
||||
<CreateGoalModal
|
||||
key={modalKey}
|
||||
visible={showCreateModal}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateData(undefined);
|
||||
}}
|
||||
onSubmit={handleCreateGoal}
|
||||
onSuccess={handleModalSuccess}
|
||||
loading={createLoading}
|
||||
initialData={selectedTemplateData}
|
||||
/>
|
||||
|
||||
{/* 目标页面引导 */}
|
||||
<GoalsPageGuide
|
||||
visible={showGuide}
|
||||
onComplete={handleGuideComplete}
|
||||
tasks={tasks}
|
||||
/>
|
||||
|
||||
{/* 开发测试按钮 */}
|
||||
<GuideTestButton visible={__DEV__} />
|
||||
|
||||
{/* 目标通知测试按钮 */}
|
||||
{__DEV__ && (
|
||||
<TouchableOpacity
|
||||
style={styles.testButton}
|
||||
onPress={() => {
|
||||
// 这里可以导航到测试页面或显示测试弹窗
|
||||
Alert.alert(
|
||||
'目标通知测试',
|
||||
'选择要测试的通知类型',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '每日目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每日运动目标',
|
||||
repeatType: 'daily',
|
||||
frequency: 1,
|
||||
hasReminder: true,
|
||||
reminderTime: '09:00',
|
||||
},
|
||||
userName
|
||||
);
|
||||
Alert.alert('成功', `每日目标通知已创建,ID: ${notificationIds.join(', ')}`);
|
||||
} catch (error) {
|
||||
Alert.alert('错误', `创建通知失败: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '每周目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每周运动目标',
|
||||
repeatType: 'weekly',
|
||||
frequency: 1,
|
||||
hasReminder: true,
|
||||
reminderTime: '10:00',
|
||||
customRepeatRule: {
|
||||
weekdays: [1, 3, 5], // 周一、三、五
|
||||
},
|
||||
},
|
||||
userName
|
||||
);
|
||||
Alert.alert('成功', `每周目标通知已创建,ID: ${notificationIds.join(', ')}`);
|
||||
} catch (error) {
|
||||
Alert.alert('错误', `创建通知失败: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '目标达成通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '小海豹';
|
||||
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
||||
Alert.alert('成功', '目标达成通知已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', `发送通知失败: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '测试庆祝动画',
|
||||
onPress: () => {
|
||||
celebrationAnimationRef.current?.play();
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.testButtonText}>测试通知</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 庆祝动画组件 */}
|
||||
<CelebrationAnimation ref={celebrationAnimationRef} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.6,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
goalsButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
pageTitle2: {
|
||||
fontSize: 16,
|
||||
fontWeight: '400',
|
||||
color: '#FFFFFF',
|
||||
lineHeight: 24,
|
||||
},
|
||||
addButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
lineHeight: 22,
|
||||
},
|
||||
|
||||
taskListContainer: {
|
||||
flex: 1,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
taskList: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20, // 避让底部导航栏 + 额外间距
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyStateImage: {
|
||||
width: 223,
|
||||
height: 59,
|
||||
marginBottom: 20,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateSubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
loadMoreContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomRightImage: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 36,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
// 任务进度卡片中的按钮样式
|
||||
cardHeaderButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
cardGoalsButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardGoalsListButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardGoalsButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
cardAddButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardAddButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
lineHeight: 18,
|
||||
},
|
||||
testButton: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
right: 20,
|
||||
backgroundColor: '#10B981',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
zIndex: 1000,
|
||||
},
|
||||
testButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -3,30 +3,33 @@ 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 { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { selectHealthDataByDate, setHealthData } from '@/store/healthSlice';
|
||||
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { fetchDailyNutritionData, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { ensureHealthPermissions, fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { getTestHealthData } from '@/utils/mockHealthData';
|
||||
import { calculateNutritionGoals } from '@/utils/nutrition';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { fetchHealthDataForDate } 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,
|
||||
@@ -39,13 +42,10 @@ import {
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
// 浮动动画组件
|
||||
const FloatingCard = ({ children, delay = 0, style }: {
|
||||
const FloatingCard = ({ children, style }: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
style?: any;
|
||||
}) => {
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -61,23 +61,19 @@ const FloatingCard = ({ children, delay = 0, style }: {
|
||||
};
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const { t } = useTranslation();
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
|
||||
|
||||
// 开发调试:设置为true来使用mock数据
|
||||
// 在真机测试时,可以暂时设置为true来验证组件显示逻辑
|
||||
const useMockData = __DEV__ || false; // 改为true来启用mock数据调试
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||
const router = useRouter();
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
// const tabBarHeight = useBottomTabBarHeight();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomPadding = useMemo(() => {
|
||||
return getTabBarBottomPadding(tabBarHeight) + (insets?.bottom ?? 0);
|
||||
}, [tabBarHeight, insets?.bottom]);
|
||||
|
||||
// 获取当前选中日期 - 使用 useMemo 缓存避免重复计算
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
const days = getMonthDaysZh();
|
||||
@@ -88,70 +84,15 @@ export default function ExploreScreen() {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
// 从 Redux 获取指定日期的健康数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
|
||||
|
||||
// 解构健康数据(支持mock数据)
|
||||
const mockData = useMockData ? getTestHealthData('mock') : null;
|
||||
const stepCount: number | null = useMockData ? (mockData?.steps ?? null) : (healthData?.steps ?? null);
|
||||
const hourlySteps = useMockData ? (mockData?.hourlySteps ?? []) : (healthData?.hourlySteps ?? []);
|
||||
const activeCalories = useMockData ? (mockData?.activeEnergyBurned ?? null) : (healthData?.activeEnergyBurned ?? null);
|
||||
const basalMetabolism: number | null = useMockData ? (mockData?.basalEnergyBurned ?? null) : (healthData?.basalEnergyBurned ?? null);
|
||||
const sleepDuration = useMockData ? (mockData?.sleepDuration ?? null) : (healthData?.sleepDuration ?? null);
|
||||
const hrvValue = useMockData ? (mockData?.hrv ?? null) : (healthData?.hrv ?? null);
|
||||
const oxygenSaturation = useMockData ? (mockData?.oxygenSaturation ?? null) : (healthData?.oxygenSaturation ?? null);
|
||||
|
||||
// 调试HRV数据
|
||||
console.log('=== HRV数据调试 ===');
|
||||
console.log('useMockData:', useMockData);
|
||||
console.log('mockData?.hrv:', mockData?.hrv);
|
||||
console.log('healthData?.hrv:', healthData?.hrv);
|
||||
console.log('final hrvValue:', hrvValue);
|
||||
console.log('healthData:', healthData);
|
||||
console.log('==================');
|
||||
|
||||
const fitnessRingsData = useMockData ? {
|
||||
activeCalories: mockData?.activeCalories ?? 0,
|
||||
activeCaloriesGoal: mockData?.activeCaloriesGoal ?? 350,
|
||||
exerciseMinutes: mockData?.exerciseMinutes ?? 0,
|
||||
exerciseMinutesGoal: mockData?.exerciseMinutesGoal ?? 30,
|
||||
standHours: mockData?.standHours ?? 0,
|
||||
standHoursGoal: mockData?.standHoursGoal ?? 12,
|
||||
} : (healthData ? {
|
||||
activeCalories: healthData.activeEnergyBurned,
|
||||
activeCaloriesGoal: healthData.activeCaloriesGoal,
|
||||
exerciseMinutes: healthData.exerciseMinutes,
|
||||
exerciseMinutesGoal: healthData.exerciseMinutesGoal,
|
||||
standHours: healthData.standHours,
|
||||
standHoursGoal: healthData.standHoursGoal,
|
||||
} : {
|
||||
activeCalories: 0,
|
||||
activeCaloriesGoal: 350,
|
||||
exerciseMinutes: 0,
|
||||
exerciseMinutesGoal: 30,
|
||||
standHours: 0,
|
||||
standHoursGoal: 12,
|
||||
});
|
||||
|
||||
// HRV更新时间
|
||||
const [hrvUpdateTime, setHrvUpdateTime] = useState<Date>(new Date());
|
||||
const handleOpenGallery = React.useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/gallery');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
|
||||
// 从 Redux 获取营养数据
|
||||
const nutritionSummary = useAppSelector(selectNutritionSummaryByDate(currentSelectedDateString));
|
||||
|
||||
// 计算用户的营养目标
|
||||
const nutritionGoals = useMemo(() => {
|
||||
return calculateNutritionGoals({
|
||||
weight: userProfile.weight,
|
||||
height: userProfile.height,
|
||||
birthDate: userProfile?.birthDate ? new Date(userProfile?.birthDate) : undefined,
|
||||
gender: userProfile?.gender || undefined,
|
||||
});
|
||||
}, [userProfile]);
|
||||
|
||||
// 心情相关状态
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -163,7 +104,6 @@ export default function ExploreScreen() {
|
||||
// 请求状态管理,防止重复请求
|
||||
const loadingRef = useRef({
|
||||
health: false,
|
||||
nutrition: false,
|
||||
mood: false
|
||||
});
|
||||
|
||||
@@ -172,14 +112,14 @@ export default function ExploreScreen() {
|
||||
|
||||
const getDateKey = (d: Date) => `${dayjs(d).year()}-${dayjs(d).month() + 1}-${dayjs(d).date()}`;
|
||||
|
||||
// 检查数据是否需要刷新(2分钟内不重复拉取,对营养数据更严格)
|
||||
// 检查数据是否需要刷新(5分钟内不重复拉取)
|
||||
const shouldRefreshData = (dateKey: string, dataType: string) => {
|
||||
const cacheKey = `${dateKey}-${dataType}`;
|
||||
const lastUpdate = dataTimestampRef.current[cacheKey];
|
||||
const now = Date.now();
|
||||
|
||||
// 营养数据使用更短的缓存时间,其他数据使用5分钟
|
||||
const cacheTime = dataType === 'nutrition' ? 2 * 60 * 1000 : 5 * 60 * 1000;
|
||||
// 使用5分钟缓存时间
|
||||
const cacheTime = 5 * 60 * 1000;
|
||||
|
||||
return !lastUpdate || (now - lastUpdate) > cacheTime;
|
||||
};
|
||||
@@ -267,20 +207,12 @@ export default function ExploreScreen() {
|
||||
loadingRef.current.health = true;
|
||||
console.log('=== 开始HealthKit初始化流程 ===');
|
||||
|
||||
const ok = await ensureHealthPermissions();
|
||||
if (!ok) {
|
||||
const errorMsg = '无法获取健康权限,请确保在真实iOS设备上运行并授权应用访问健康数据';
|
||||
console.warn(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
latestRequestKeyRef.current = requestKey;
|
||||
|
||||
console.log('权限获取成功,开始获取健康数据...', derivedDate);
|
||||
const data = await fetchHealthDataForDate(derivedDate);
|
||||
|
||||
console.log('设置UI状态:', data);
|
||||
console.log('HRV数据详细信息:', data.hrv, typeof data.hrv);
|
||||
|
||||
// 仅当该请求仍是最新时,才应用结果
|
||||
if (latestRequestKeyRef.current === requestKey) {
|
||||
@@ -290,12 +222,7 @@ export default function ExploreScreen() {
|
||||
dispatch(setHealthData({
|
||||
date: dateString,
|
||||
data: {
|
||||
steps: data.steps,
|
||||
activeCalories: data.activeEnergyBurned,
|
||||
basalEnergyBurned: data.basalEnergyBurned,
|
||||
sleepDuration: data.sleepDuration,
|
||||
hrv: data.hrv,
|
||||
oxygenSaturation: data.oxygenSaturation,
|
||||
heartRate: data.heartRate,
|
||||
activeEnergyBurned: data.activeEnergyBurned,
|
||||
activeCaloriesGoal: data.activeCaloriesGoal,
|
||||
@@ -303,12 +230,9 @@ export default function ExploreScreen() {
|
||||
exerciseMinutesGoal: data.exerciseMinutesGoal,
|
||||
standHours: data.standHours,
|
||||
standHoursGoal: data.standHoursGoal,
|
||||
hourlySteps: data.hourlySteps,
|
||||
}
|
||||
}));
|
||||
|
||||
// 更新HRV数据时间
|
||||
setHrvUpdateTime(new Date());
|
||||
setAnimToken((t) => t + 1);
|
||||
|
||||
// 更新缓存时间戳
|
||||
@@ -326,45 +250,6 @@ export default function ExploreScreen() {
|
||||
};
|
||||
|
||||
// 加载营养数据
|
||||
const loadNutritionData = async (targetDate?: Date, forceRefresh = false) => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 确定要查询的日期
|
||||
let derivedDate: Date;
|
||||
if (targetDate) {
|
||||
derivedDate = targetDate;
|
||||
} else {
|
||||
derivedDate = currentSelectedDate;
|
||||
}
|
||||
|
||||
const requestKey = getDateKey(derivedDate);
|
||||
|
||||
// 检查是否正在加载或不需要刷新
|
||||
if (loadingRef.current.nutrition) {
|
||||
console.log('营养数据正在加载中,跳过重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!forceRefresh && !shouldRefreshData(requestKey, 'nutrition')) {
|
||||
console.log('营养数据缓存未过期,跳过请求');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingRef.current.nutrition = true;
|
||||
console.log('加载营养数据...', derivedDate);
|
||||
await dispatch(fetchDailyNutritionData(derivedDate));
|
||||
console.log('营养数据加载完成');
|
||||
|
||||
// 更新缓存时间戳
|
||||
updateDataTimestamp(requestKey, 'nutrition');
|
||||
|
||||
} catch (error) {
|
||||
console.error('营养数据加载失败:', error);
|
||||
} finally {
|
||||
loadingRef.current.nutrition = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 实际执行数据加载的方法
|
||||
const executeLoadAllData = React.useCallback((targetDate?: Date, forceRefresh = false) => {
|
||||
@@ -373,7 +258,6 @@ export default function ExploreScreen() {
|
||||
console.log('执行数据加载,日期:', dateToUse, '强制刷新:', forceRefresh);
|
||||
loadHealthData(dateToUse, forceRefresh);
|
||||
if (isLoggedIn) {
|
||||
loadNutritionData(dateToUse, forceRefresh);
|
||||
loadMoodData(dateToUse, forceRefresh);
|
||||
// 加载喝水数据(只加载今日数据用于后台检查)
|
||||
const isToday = dayjs(dateToUse).isSame(dayjs(), 'day');
|
||||
@@ -401,14 +285,63 @@ export default function ExploreScreen() {
|
||||
}
|
||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||
|
||||
// 页面聚焦时的数据加载逻辑
|
||||
// useFocusEffect(
|
||||
// React.useCallback(() => {
|
||||
// // 页面聚焦时加载数据,使用缓存机制避免频繁请求
|
||||
// console.log('页面聚焦,检查是否需要刷新数据...');
|
||||
// loadAllData(currentSelectedDate);
|
||||
// }, [loadAllData, currentSelectedDate])
|
||||
// );
|
||||
// 同步 HealthKit 数据到服务端(带智能 diff 比较)
|
||||
const syncHealthDataToServer = React.useCallback(async () => {
|
||||
if (!isLoggedIn || !userProfile) {
|
||||
logger.info('用户未登录,跳过 HealthKit 数据同步');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
|
||||
|
||||
// 1. 同步个人资料 (身高、体重、出生日期)
|
||||
// 传入当前用户资料,用于 diff 比较
|
||||
const success = await syncHealthKitToServer(
|
||||
async (data) => {
|
||||
await dispatch(updateUserProfile(data) as any);
|
||||
},
|
||||
userProfile // 传入当前用户资料进行比较
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logger.info('HealthKit 个人资料同步到服务端成功');
|
||||
} else {
|
||||
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
|
||||
}
|
||||
|
||||
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
|
||||
// 传入今日饮水量
|
||||
const waterIntake = todayWaterStats?.totalAmount;
|
||||
logger.info('开始同步每日健康数据报表...', { waterIntake });
|
||||
|
||||
const reportSuccess = await syncDailyHealthReport(waterIntake);
|
||||
|
||||
if (reportSuccess) {
|
||||
logger.info('每日健康数据报表同步成功');
|
||||
} else {
|
||||
logger.info('每日健康数据报表同步跳过(无变化)或失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('同步 HealthKit 数据到服务端失败:', error);
|
||||
}
|
||||
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
|
||||
|
||||
// 初始加载时执行数据加载和同步
|
||||
useEffect(() => {
|
||||
loadAllData(currentSelectedDate);
|
||||
|
||||
// 延迟1秒后执行同步,避免影响初始加载性能
|
||||
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
|
||||
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
|
||||
const syncTimer = setTimeout(() => {
|
||||
syncHealthDataToServer();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(syncTimer);
|
||||
}, [])
|
||||
|
||||
|
||||
// AppState 监听:应用从后台返回前台时的处理
|
||||
useEffect(() => {
|
||||
@@ -454,7 +387,7 @@ export default function ExploreScreen() {
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
@@ -468,7 +401,7 @@ export default function ExploreScreen() {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top,
|
||||
paddingBottom: bottomPadding,
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 20
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -476,42 +409,41 @@ export default function ExploreScreen() {
|
||||
{/* 顶部信息栏 */}
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.headerContent}>
|
||||
{/* 左边logo */}
|
||||
<Image
|
||||
source={require('@/assets/images/Sealife.jpeg')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.headerLeft}>
|
||||
<Image
|
||||
source={require('@/assets/machine.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 右边文字区域 */}
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>Out Live</Text>
|
||||
{/* 右边文字区域 */}
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开发环境调试按钮 */}
|
||||
{__DEV__ && (
|
||||
<View style={styles.debugButtonsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.debugButton}
|
||||
onPress={async () => {
|
||||
console.log('🔧 手动触发后台任务测试...');
|
||||
await backgroundTaskManager.triggerTaskForTesting();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🔧</Text>
|
||||
</TouchableOpacity>
|
||||
<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>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.debugButton, styles.hrvTestButton]}
|
||||
onPress={async () => {
|
||||
console.log('🫀 测试HRV数据获取...');
|
||||
await testHRVDataFetch();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🫀</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -529,27 +461,27 @@ export default function ExploreScreen() {
|
||||
|
||||
{/* 营养摄入雷达图卡片 */}
|
||||
<NutritionRadarCard
|
||||
nutritionSummary={nutritionSummary}
|
||||
nutritionGoals={nutritionGoals}
|
||||
burnedCalories={(basalMetabolism || 0) + (activeCalories || 0)}
|
||||
basalMetabolism={basalMetabolism || 0}
|
||||
activeCalories={activeCalories || 0}
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
onMealPress={(mealType: 'breakfast' | 'lunch' | 'dinner' | 'snack') => {
|
||||
console.log('选择餐次:', mealType);
|
||||
// 这里可以导航到营养记录页面
|
||||
pushIfAuthedElseLogin('/nutrition/records');
|
||||
}}
|
||||
/>
|
||||
|
||||
<WeightHistoryCard />
|
||||
|
||||
<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} delay={1500}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<MoodCard
|
||||
moodCheckin={currentMoodCheckin}
|
||||
onPress={() => pushIfAuthedElseLogin('/mood/calendar')}
|
||||
@@ -559,21 +491,16 @@ export default function ExploreScreen() {
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StepsCard
|
||||
stepCount={stepCount}
|
||||
curDate={currentSelectedDate}
|
||||
stepGoal={stepGoal}
|
||||
hourlySteps={hourlySteps}
|
||||
style={styles.stepsCardOverride}
|
||||
onPress={() => pushIfAuthedElseLogin('/steps/detail')}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
|
||||
<FloatingCard style={styles.masonryCard} delay={0}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StressMeter
|
||||
value={hrvValue}
|
||||
updateTime={hrvUpdateTime}
|
||||
hrvValue={hrvValue}
|
||||
curDate={currentSelectedDate}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
@@ -587,28 +514,22 @@ export default function ExploreScreen() {
|
||||
</FloatingCard> */}
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<SleepCard
|
||||
sleepDuration={sleepDuration}
|
||||
onPress={() => pushIfAuthedElseLogin('/sleep-detail')}
|
||||
<SleepCard
|
||||
selectedDate={currentSelectedDate}
|
||||
/>
|
||||
</FloatingCard>
|
||||
</View>
|
||||
|
||||
{/* 右列 */}
|
||||
<View style={styles.masonryColumn}>
|
||||
<FloatingCard style={styles.masonryCard} delay={250}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<FitnessRingsCard
|
||||
activeCalories={fitnessRingsData.activeCalories}
|
||||
activeCaloriesGoal={fitnessRingsData.activeCaloriesGoal}
|
||||
exerciseMinutes={fitnessRingsData.exerciseMinutes}
|
||||
exerciseMinutesGoal={fitnessRingsData.exerciseMinutesGoal}
|
||||
standHours={fitnessRingsData.standHours}
|
||||
standHoursGoal={fitnessRingsData.standHoursGoal}
|
||||
selectedDate={currentSelectedDate}
|
||||
resetToken={animToken}
|
||||
/>
|
||||
</FloatingCard>
|
||||
{/* 饮水记录卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={500}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<WaterIntakeCard
|
||||
selectedDate={currentSelectedDateString}
|
||||
style={styles.waterCardOverride}
|
||||
@@ -617,27 +538,30 @@ export default function ExploreScreen() {
|
||||
|
||||
|
||||
{/* 基础代谢卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1250}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<BasalMetabolismCard
|
||||
value={basalMetabolism}
|
||||
resetToken={animToken}
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
{/* 血氧饱和度卡片 */}
|
||||
<FloatingCard style={styles.masonryCard} delay={1750}>
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<OxygenSaturationCard
|
||||
resetToken={animToken}
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
oxygenSaturation={oxygenSaturation}
|
||||
/>
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
</View>
|
||||
</View>
|
||||
<WeightHistoryCard />
|
||||
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<CircumferenceCard style={styles.circumferenceCard} />
|
||||
</ScrollView>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -686,10 +610,17 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
logoImage: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 20,
|
||||
},
|
||||
headerTextContainer: {
|
||||
@@ -698,8 +629,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
debugButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -721,20 +653,9 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
hrvTestButton: {
|
||||
backgroundColor: '#8B5CF6',
|
||||
},
|
||||
debugButtonText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginTop: 24,
|
||||
marginBottom: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -768,13 +689,15 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
lineHeight: 18,
|
||||
fontWeight: '600',
|
||||
textAlignVertical: 'bottom'
|
||||
textAlignVertical: 'bottom',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
caloriesUnit: {
|
||||
color: '#515558ff',
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
trainingContent: {
|
||||
marginTop: 8,
|
||||
@@ -808,6 +731,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#8B74F3',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
cyclingHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -827,6 +751,7 @@ const styles = StyleSheet.create({
|
||||
color: '#FFFFFF',
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
mapArea: {
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
@@ -866,6 +791,7 @@ const styles = StyleSheet.create({
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
heartCard: {
|
||||
backgroundColor: '#FFE5E5',
|
||||
@@ -886,12 +812,14 @@ const styles = StyleSheet.create({
|
||||
alignSelf: 'flex-end',
|
||||
color: '#5B5B5B',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
stepsValue: {
|
||||
fontSize: 14,
|
||||
color: '#7A6A42',
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -907,6 +835,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
retryButton: {
|
||||
padding: 4,
|
||||
@@ -921,11 +850,13 @@ const styles = StyleSheet.create({
|
||||
viewMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
viewMoreIcon: {
|
||||
fontSize: 16,
|
||||
color: '#192126',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
stressCardRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -938,7 +869,6 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 16,
|
||||
},
|
||||
masonryContainer: {
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginTop: 6,
|
||||
@@ -972,6 +902,9 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
height: '100%', // 填充整个masonryCard
|
||||
},
|
||||
workoutCardOverride: {
|
||||
marginTop: 16,
|
||||
},
|
||||
waterCardOverride: {
|
||||
margin: -16, // 抵消 masonryCard 的 padding
|
||||
borderRadius: 16,
|
||||
@@ -994,6 +927,7 @@ const styles = StyleSheet.create({
|
||||
color: '#0369A1',
|
||||
fontWeight: '800',
|
||||
marginTop: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
addWeightButton: {
|
||||
position: 'absolute',
|
||||
@@ -1001,6 +935,68 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
610
app/_layout.tsx
@@ -1,133 +1,533 @@
|
||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||
import { clearAiCoachSessionCache } from '@/services/aiCoachSession';
|
||||
import { backgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { notificationService } from '@/services/notifications';
|
||||
import '@/i18n';
|
||||
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 { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||
import { fetchMyProfile, logout, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { MoodNotificationHelpers, NutritionNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
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 from 'react';
|
||||
import RNExitApp from 'react-native-exit-app';
|
||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
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, profile } = 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 initializeNotifications = async () => {
|
||||
const handle401 = async () => {
|
||||
try {
|
||||
// 初始化通知服务
|
||||
await notificationService.initialize();
|
||||
console.log('通知服务初始化成功');
|
||||
logger.info('[401处理] 开始处理登录过期');
|
||||
|
||||
// 清除Redux状态
|
||||
await dispatch(logout());
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/auth/login');
|
||||
|
||||
logger.info('[401处理] 登录过期处理完成');
|
||||
} catch (error) {
|
||||
logger.error('[401处理] 处理失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化后台任务管理器
|
||||
await backgroundTaskManager.initialize();
|
||||
console.log('后台任务管理器初始化成功');
|
||||
setUnauthorizedHandler(handle401);
|
||||
logger.info('[401处理器] 已注册到API服务');
|
||||
}, [dispatch, router]);
|
||||
|
||||
// 初始化快捷动作
|
||||
React.useEffect(() => {
|
||||
if (fastingHydrationRequestedRef.current) return;
|
||||
if (activeFastingSchedule) {
|
||||
fastingHydrationRequestedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
console.log('快捷动作初始化成功');
|
||||
logger.info('✅ 快捷动作初始化完成');
|
||||
|
||||
// 初始化喝水记录 bridge
|
||||
// 5. 初始化喝水记录 Bridge
|
||||
initializeWaterRecordBridge();
|
||||
console.log('喝水记录 Bridge 初始化成功');
|
||||
logger.info('✅ 喝水记录 Bridge 初始化完成');
|
||||
|
||||
// 检查并同步Widget数据更改
|
||||
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) {
|
||||
console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||
|
||||
// 将待同步的记录添加到 Redux store
|
||||
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, // 标记为自动添加(来自Widget)
|
||||
source: WaterRecordSource.Auto,
|
||||
})).unwrap();
|
||||
|
||||
console.log(`成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
||||
|
||||
logger.info(`✅ 成功同步水记录: ${record.amount}ml at ${record.recordedAt}`);
|
||||
} catch (error) {
|
||||
console.error('同步水记录失败:', error);
|
||||
logger.error('❌ 同步水记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 清除已同步的记录
|
||||
await clearPendingWaterRecords();
|
||||
console.log('所有待同步的水记录已处理完成');
|
||||
logger.info('✅ 所有待同步的水记录已处理完成');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('通知服务、后台任务管理器或快捷动作初始化失败:', error);
|
||||
logger.error('❌ Widget 数据同步失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadUserData();
|
||||
initializeNotifications();
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
}, [dispatch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// 当用户数据加载完成后,检查是否需要显示隐私同意弹窗
|
||||
if (userDataLoaded && !privacyAgreed) {
|
||||
setShowPrivacyModal(true);
|
||||
}
|
||||
}, [userDataLoaded, privacyAgreed]);
|
||||
|
||||
// 当用户数据加载完成且用户名存在时,注册所有提醒
|
||||
React.useEffect(() => {
|
||||
const registerAllReminders = async () => {
|
||||
// 批量注册所有通知提醒
|
||||
const registerAllNotifications = async () => {
|
||||
try {
|
||||
await notificationService.initialize();
|
||||
// 后台任务
|
||||
await backgroundTaskManager.initialize()
|
||||
// 注册午餐提醒(12:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
||||
console.log('午餐提醒已注册');
|
||||
logger.info('📢 开始批量注册通知提醒...');
|
||||
|
||||
// 注册晚餐提醒(18:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
|
||||
console.log('晚餐提醒已注册');
|
||||
// 获取用户偏好设置
|
||||
const [nutritionReminderEnabled, moodReminderEnabled, waterSettings] = await Promise.all([
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
getWaterReminderSettings(),
|
||||
]);
|
||||
|
||||
// 注册心情提醒(21:00)
|
||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
|
||||
console.log('心情提醒已注册');
|
||||
// 准备所有通知注册任务
|
||||
const notificationTasks = [];
|
||||
|
||||
// 营养提醒 - 根据用户设置决定是否注册
|
||||
if (nutritionReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 午餐提醒已注册')
|
||||
),
|
||||
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 晚餐提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启营养提醒,跳过注册');
|
||||
}
|
||||
|
||||
console.log('喝水提醒后台任务已注册');
|
||||
// 心情提醒 - 根据用户设置决定是否注册
|
||||
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) {
|
||||
console.error('注册提醒失败:', error);
|
||||
logger.error('❌ 通知提醒注册失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
registerAllReminders();
|
||||
}, [userDataLoaded, profile?.name]);
|
||||
// 初始化后台任务管理器
|
||||
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();
|
||||
}, []);
|
||||
|
||||
// 使用 ref 确保更新检查只执行一次
|
||||
// const updateCheckRequestedRef = React.useRef(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// // 如果已经执行过更新检查,直接返回
|
||||
// if (updateCheckRequestedRef.current) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// updateCheckRequestedRef.current = true;
|
||||
|
||||
// async function checkUpdate() {
|
||||
// try {
|
||||
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
|
||||
|
||||
// // 只有在 expo-updates 启用时才检查更新
|
||||
// // 开发环境或 Updates 未启用时跳过
|
||||
// if (__DEV__ || !Updates.isEnabled) {
|
||||
// logger.info('Skipping update check: dev mode or updates not enabled');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const update = await Updates.checkForUpdateAsync();
|
||||
// logger.info("Update check:", update);
|
||||
|
||||
// if (update.isAvailable) {
|
||||
// logger.info("Update available, fetching...");
|
||||
// const result = await Updates.fetchUpdateAsync();
|
||||
// logger.info("Fetch result:", result);
|
||||
|
||||
// if (result.isNew) {
|
||||
// logger.info("Reloading app to apply update...");
|
||||
// await Updates.reloadAsync();
|
||||
// }
|
||||
// }
|
||||
// } catch (e) {
|
||||
// logger.error("Update error:", e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// setTimeout(() => {
|
||||
// checkUpdate();
|
||||
// }, 5000);
|
||||
// }, []);
|
||||
|
||||
const handlePrivacyAgree = () => {
|
||||
dispatch(setPrivacyAgreed());
|
||||
@@ -135,17 +535,19 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
|
||||
const handlePrivacyDisagree = () => {
|
||||
RNExitApp.exitApp();
|
||||
// RNExitApp.exitApp();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogProvider>
|
||||
{children}
|
||||
<PrivacyConsentModal
|
||||
visible={showPrivacyModal}
|
||||
onAgree={handlePrivacyAgree}
|
||||
onDisagree={handlePrivacyDisagree}
|
||||
/>
|
||||
<MembershipModalProvider>
|
||||
{children}
|
||||
<PrivacyConsentModal
|
||||
visible={showPrivacyModal}
|
||||
onAgree={handlePrivacyAgree}
|
||||
onDisagree={handlePrivacyDisagree}
|
||||
/>
|
||||
</MembershipModalProvider>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
@@ -153,6 +555,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
export default function RootLayout() {
|
||||
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) {
|
||||
@@ -165,26 +569,32 @@ export default function RootLayout() {
|
||||
<Provider store={store}>
|
||||
<Bootstrapper>
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
|
||||
<VersionCheckProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
</ThemeProvider>
|
||||
<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" />
|
||||
</ThemeProvider>
|
||||
</VersionCheckProvider>
|
||||
</ToastProvider>
|
||||
</Bootstrapper>
|
||||
</Provider>
|
||||
|
||||
@@ -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={Colors.light.accentGreen} />
|
||||
<Text style={styles.placeholderTitle}>上传中...</Text>
|
||||
</View>
|
||||
) : value ? (
|
||||
<Image source={{ uri: value }} style={styles.preview} />
|
||||
) : (
|
||||
<View style={styles.placeholder}>
|
||||
<View style={styles.plusBadge}>
|
||||
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
|
||||
</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: Colors.light.accentGreen,
|
||||
},
|
||||
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: Colors.light.accentGreen,
|
||||
},
|
||||
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: Colors.light.accentGreen,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 24,
|
||||
},
|
||||
grid: {
|
||||
width: RING_SIZE * 0.9,
|
||||
height: RING_SIZE * 0.9,
|
||||
borderRadius: RING_SIZE * 0.45,
|
||||
overflow: 'hidden',
|
||||
padding: 10,
|
||||
backgroundColor: 'rgba(25,33,38,0.08)',
|
||||
},
|
||||
gridRow: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
gridCell: {
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
margin: 2,
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||
},
|
||||
scanner: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '50%',
|
||||
height: 60,
|
||||
marginTop: -30,
|
||||
backgroundColor: 'rgba(187,242,70,0.10)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(187,242,70,0.25)',
|
||||
},
|
||||
particleA: {
|
||||
position: 'absolute',
|
||||
right: SCREEN_WIDTH * 0.18,
|
||||
top: 40,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: Colors.light.accentGreen,
|
||||
shadowColor: Colors.light.accentGreen,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
particleB: {
|
||||
position: 'absolute',
|
||||
right: SCREEN_WIDTH * 0.08,
|
||||
top: 120,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(89, 198, 255, 1)',
|
||||
shadowColor: 'rgba(89, 198, 255, 1)',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
panel: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 8,
|
||||
},
|
||||
title: {
|
||||
color: '#ECEDEE',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: 14,
|
||||
},
|
||||
primaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
height: 44,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: '#192126',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
secondaryBtn: {
|
||||
flex: 1,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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('/(tabs)/coach')}>
|
||||
<Text style={[styles.secondaryBtnText, { color: theme.text }]}>生成训练建议</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
bgBlobA: {
|
||||
position: 'absolute',
|
||||
top: -60,
|
||||
right: -40,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
backgroundColor: 'rgba(187,242,70,0.18)',
|
||||
},
|
||||
bgBlobB: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: -30,
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 90,
|
||||
backgroundColor: 'rgba(89, 198, 255, 0.16)',
|
||||
},
|
||||
card: {
|
||||
marginTop: 16,
|
||||
marginHorizontal: 16,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(25,33,38,0.08)',
|
||||
},
|
||||
sectionTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
marginBottom: 8,
|
||||
},
|
||||
overview: {
|
||||
color: '#384046',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
radarWrap: {
|
||||
marginTop: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
cardTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
scoreBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(187,242,70,0.16)',
|
||||
},
|
||||
scoreText: {
|
||||
color: '#192126',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
scoreUnit: {
|
||||
color: '#5E6468',
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
issueItem: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
issueDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginTop: 6,
|
||||
},
|
||||
dotHigh: { backgroundColor: '#E24D4D' },
|
||||
dotMedium: { backgroundColor: '#F0C23C' },
|
||||
dotLow: { backgroundColor: '#2BCC7F' },
|
||||
issueTitle: {
|
||||
color: '#192126',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
issueDesc: {
|
||||
color: '#5E6468',
|
||||
fontSize: 13,
|
||||
marginTop: 4,
|
||||
},
|
||||
suggestRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
suggestChip: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(25,33,38,0.04)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(25,33,38,0.08)',
|
||||
},
|
||||
suggestText: {
|
||||
color: '#192126',
|
||||
fontSize: 12,
|
||||
},
|
||||
actions: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
primaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
height: 48,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 14,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: '#192126',
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
secondaryBtn: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#384046',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,34 +112,47 @@ 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 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 {
|
||||
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;
|
||||
if (paramsJson) {
|
||||
try { parsedParams = JSON.parse(paramsJson); } catch { }
|
||||
}
|
||||
if (to) {
|
||||
router.replace({ pathname: to, params: parsedParams } as any);
|
||||
} 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]);
|
||||
|
||||
|
||||
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
|
||||
@@ -214,35 +230,83 @@ export default function LoginScreen() {
|
||||
</View>
|
||||
{/* 自定义头部,与其它页面风格一致 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: color.text }]}>登录</Text>
|
||||
{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 }]}>{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 }]}>Sealife</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Sealife</ThemedText>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>{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}
|
||||
>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
</Pressable>
|
||||
{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}>{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>
|
||||
)}
|
||||
|
||||
{/* 协议勾选 */}
|
||||
@@ -257,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>
|
||||
|
||||
@@ -291,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,
|
||||
@@ -314,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,176 +0,0 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { completeDay, setCustom } from '@/store/challengeSlice';
|
||||
import type { Exercise, ExerciseCustomConfig } from '@/utils/pilatesPlan';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ChallengeDayScreen() {
|
||||
const { day } = useLocalSearchParams<{ day: string }>();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||
const dayNumber = Math.max(1, Math.min(30, parseInt(String(day || '1'), 10)));
|
||||
const dayState = challenge?.days?.[dayNumber - 1];
|
||||
const [currentSetIndexByExercise, setCurrentSetIndexByExercise] = useState<Record<string, number>>({});
|
||||
const [custom, setCustomLocal] = useState<ExerciseCustomConfig[]>(dayState?.custom || []);
|
||||
|
||||
const isLocked = dayState?.status === 'locked';
|
||||
const isCompleted = dayState?.status === 'completed';
|
||||
const plan = dayState?.plan;
|
||||
|
||||
// 不再强制所有动作完成,始终允许完成
|
||||
const canFinish = true;
|
||||
|
||||
const handleNextSet = (ex: Exercise) => {
|
||||
const curr = currentSetIndexByExercise[ex.key] ?? 0;
|
||||
if (curr < ex.sets.length) {
|
||||
setCurrentSetIndexByExercise((prev) => ({ ...prev, [ex.key]: curr + 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
// 持久化自定义配置
|
||||
await dispatch(setCustom({ dayNumber, custom: custom }));
|
||||
await dispatch(completeDay(dayNumber));
|
||||
router.back();
|
||||
};
|
||||
|
||||
const updateCustom = (key: string, partial: Partial<ExerciseCustomConfig>) => {
|
||||
setCustomLocal((prev) => {
|
||||
const next = prev.map((c) => (c.key === key ? { ...c, ...partial } : c));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}><Text>加载中...</Text></View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title={`第${plan.dayNumber}天`} onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<Text style={styles.title}>{plan.title}</Text>
|
||||
<Text style={styles.subtitle}>{plan.focus}</Text>
|
||||
|
||||
<FlatList
|
||||
data={plan.exercises}
|
||||
keyExtractor={(item) => item.key}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 120 }}
|
||||
renderItem={({ item }) => {
|
||||
const doneSets = currentSetIndexByExercise[item.key] ?? 0;
|
||||
const conf = custom.find((c) => c.key === item.key);
|
||||
const targetSets = conf?.sets ?? item.sets.length;
|
||||
const perSetDuration = conf?.durationSec ?? item.sets[0]?.durationSec ?? 40;
|
||||
return (
|
||||
<View style={styles.exerciseCard}>
|
||||
<View style={styles.exerciseHeader}>
|
||||
<Text style={styles.exerciseName}>{item.name}</Text>
|
||||
<Text style={styles.exerciseDesc}>{item.description}</Text>
|
||||
</View>
|
||||
<View style={styles.controlsRow}>
|
||||
<TouchableOpacity style={[styles.toggleBtn, conf?.enabled === false && styles.toggleBtnOff]} onPress={() => updateCustom(item.key, { enabled: !(conf?.enabled ?? true) })}>
|
||||
<Text style={styles.toggleBtnText}>{conf?.enabled === false ? '已关闭' : '已启用'}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.counterBox}>
|
||||
<Text style={styles.counterLabel}>组数</Text>
|
||||
<View style={styles.counterRow}>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.max(1, (conf?.sets ?? targetSets) - 1) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
||||
<Text style={styles.counterValue}>{conf?.sets ?? targetSets}</Text>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { sets: Math.min(10, (conf?.sets ?? targetSets) + 1) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.counterBox}>
|
||||
<Text style={styles.counterLabel}>时长/组</Text>
|
||||
<View style={styles.counterRow}>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.max(10, (conf?.durationSec ?? perSetDuration) - 5) })}><Text style={styles.counterBtnText}>-</Text></TouchableOpacity>
|
||||
<Text style={styles.counterValue}>{conf?.durationSec ?? perSetDuration}s</Text>
|
||||
<TouchableOpacity style={styles.counterBtn} onPress={() => updateCustom(item.key, { durationSec: Math.min(180, (conf?.durationSec ?? perSetDuration) + 5) })}><Text style={styles.counterBtnText}>+</Text></TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.setsRow}>
|
||||
{Array.from({ length: targetSets }).map((_, idx) => (
|
||||
<View key={idx} style={[styles.setPill, idx < doneSets ? styles.setPillDone : styles.setPillTodo]}>
|
||||
<Text style={[styles.setPillText, idx < doneSets ? styles.setPillTextDone : styles.setPillTextTodo]}>
|
||||
{perSetDuration}s
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity style={styles.nextSetBtn} onPress={() => handleNextSet(item)} disabled={doneSets >= targetSets || conf?.enabled === false}>
|
||||
<Text style={styles.nextSetText}>{doneSets >= item.sets.length ? '本动作完成' : '完成一组'}</Text>
|
||||
</TouchableOpacity>
|
||||
{item.tips && (
|
||||
<View style={styles.tipsBox}>
|
||||
{item.tips.map((t: string, i: number) => (
|
||||
<Text key={i} style={styles.tipText}>• {t}</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity style={[styles.finishBtn, !canFinish && { opacity: 0.5 }]} disabled={!canFinish || isLocked || isCompleted} onPress={handleComplete}>
|
||||
<Text style={styles.finishBtnText}>{isCompleted ? '已完成' : '完成今日训练'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
header: { paddingHorizontal: 20, paddingTop: 10, paddingBottom: 10 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||
headerTitle: { fontSize: 18, fontWeight: '800', color: '#1A1A1A' },
|
||||
title: { marginTop: 6, fontSize: 20, fontWeight: '800', color: '#1A1A1A' },
|
||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
exerciseCard: {
|
||||
backgroundColor: '#FFFFFF', borderRadius: 16, padding: 16, marginTop: 12,
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
},
|
||||
exerciseHeader: { marginBottom: 8 },
|
||||
exerciseName: { fontSize: 16, fontWeight: '800', color: '#111827' },
|
||||
exerciseDesc: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||
setsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
|
||||
controlsRow: { flexDirection: 'row', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 8 },
|
||||
toggleBtn: { backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
||||
toggleBtnOff: { backgroundColor: '#9CA3AF' },
|
||||
toggleBtnText: { color: '#FFFFFF', fontWeight: '700' },
|
||||
counterBox: { backgroundColor: '#F3F4F6', borderRadius: 8, padding: 8 },
|
||||
counterLabel: { fontSize: 10, color: '#6B7280' },
|
||||
counterRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
counterBtn: { backgroundColor: '#E5E7EB', width: 28, height: 28, borderRadius: 6, alignItems: 'center', justifyContent: 'center' },
|
||||
counterBtnText: { fontWeight: '800', color: '#111827' },
|
||||
counterValue: { minWidth: 40, textAlign: 'center', fontWeight: '700', color: '#111827' },
|
||||
setPill: { paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 },
|
||||
setPillTodo: { backgroundColor: '#F3F4F6' },
|
||||
setPillDone: { backgroundColor: Colors.light.accentGreen },
|
||||
setPillText: { fontSize: 12, fontWeight: '700' },
|
||||
setPillTextTodo: { color: '#6B7280' },
|
||||
setPillTextDone: { color: '#192126' },
|
||||
nextSetBtn: { marginTop: 10, alignSelf: 'flex-start', backgroundColor: '#111827', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 8 },
|
||||
nextSetText: { color: '#FFFFFF', fontWeight: '700' },
|
||||
tipsBox: { marginTop: 10, backgroundColor: '#F9FAFB', borderRadius: 8, padding: 10 },
|
||||
tipText: { fontSize: 12, color: '#6B7280', lineHeight: 18 },
|
||||
bottomBar: { position: 'absolute', left: 0, right: 0, bottom: 0, padding: 20, backgroundColor: 'transparent' },
|
||||
finishBtn: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
||||
finishBtnText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { initChallenge } from '@/store/challengeSlice';
|
||||
import { estimateSessionMinutesWithCustom } from '@/utils/pilatesPlan';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Dimensions, FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ChallengeHomeScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const challenge = useAppSelector((s) => (s as any).challenge);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(initChallenge());
|
||||
}, [dispatch]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const total = challenge?.days?.length || 30;
|
||||
const done = challenge?.days?.filter((d: any) => d.status === 'completed').length || 0;
|
||||
return total ? done / total : 0;
|
||||
}, [challenge?.days]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title="30天普拉提打卡" onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<Text style={styles.subtitle}>专注核心、体态与柔韧 · 连续完成解锁徽章</Text>
|
||||
|
||||
{/* 进度环与统计 */}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryLeft}>
|
||||
<View style={styles.progressPill}>
|
||||
<View style={[styles.progressFill, { width: `${Math.round((progress || 0) * 100)}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>{Math.round((progress || 0) * 100)}%</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRight}>
|
||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{challenge?.streak ?? 0}</Text> 天连续</Text>
|
||||
<Text style={styles.summaryItem}><Text style={styles.summaryItemValue}>{(challenge?.days?.filter((d: any) => d.status === 'completed').length) ?? 0}</Text> / 30 完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 日历格子(简单 6x5 网格) */}
|
||||
<FlatList
|
||||
data={challenge?.days || []}
|
||||
keyExtractor={(item) => String(item.plan.dayNumber)}
|
||||
numColumns={5}
|
||||
columnWrapperStyle={{ justifyContent: 'space-between', marginBottom: 12 }}
|
||||
contentContainerStyle={{ paddingHorizontal: 20, paddingTop: 10, paddingBottom: 40 }}
|
||||
renderItem={({ item }) => {
|
||||
const { plan, status } = item;
|
||||
const isLocked = status === 'locked';
|
||||
const isCompleted = status === 'completed';
|
||||
const minutes = estimateSessionMinutesWithCustom(plan, item.custom);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={isLocked}
|
||||
onPress={async () => {
|
||||
if (!(await ensureLoggedIn({ redirectTo: '/challenge', redirectParams: {} }))) return;
|
||||
router.push({ pathname: '/challenge/day', params: { day: String(plan.dayNumber) } });
|
||||
}}
|
||||
style={[styles.dayCell, isLocked && styles.dayCellLocked, isCompleted && styles.dayCellCompleted]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.dayNumber, isLocked && styles.dayNumberLocked]}>{plan.dayNumber}</Text>
|
||||
<Text style={styles.dayMinutes}>{minutes}′</Text>
|
||||
{isCompleted && <Ionicons name="checkmark-circle" size={18} color="#10B981" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
||||
{isLocked && <Ionicons name="lock-closed" size={16} color="#9CA3AF" style={{ position: 'absolute', top: 6, right: 6 }} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 底部 CTA */}
|
||||
<View style={styles.bottomBar}>
|
||||
<TouchableOpacity style={styles.startButton} onPress={async () => {
|
||||
if (!(await ensureLoggedIn({ redirectTo: '/challenge' }))) return;
|
||||
router.push({ pathname: '/challenge/day', params: { day: String((challenge?.days?.find((d: any) => d.status === 'available')?.plan.dayNumber) || 1) } });
|
||||
}}>
|
||||
<Text style={styles.startButtonText}>开始今日训练</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const cellSize = (width - 40 - 4 * 12) / 5; // 20 padding *2, 12 spacing *4
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
container: { flex: 1, backgroundColor: '#F7F8FA' },
|
||||
header: { paddingHorizontal: 20, paddingTop: 10 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: '#E5E7EB' },
|
||||
headerTitle: { fontSize: 22, fontWeight: '800', color: '#1A1A1A' },
|
||||
subtitle: { marginTop: 6, fontSize: 12, color: '#6B7280' },
|
||||
summaryCard: {
|
||||
marginTop: 16,
|
||||
marginHorizontal: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
},
|
||||
summaryLeft: { flexDirection: 'row', alignItems: 'center' },
|
||||
progressPill: { width: 120, height: 10, borderRadius: 999, backgroundColor: '#E5E7EB', overflow: 'hidden' },
|
||||
progressFill: { height: '100%', backgroundColor: Colors.light.accentGreen },
|
||||
progressText: { marginLeft: 12, fontWeight: '700', color: '#111827' },
|
||||
summaryRight: {},
|
||||
summaryItem: { fontSize: 12, color: '#6B7280' },
|
||||
summaryItemValue: { fontWeight: '800', color: '#111827' },
|
||||
dayCell: {
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000', shadowOpacity: 0.06, shadowRadius: 12, shadowOffset: { width: 0, height: 6 }, elevation: 3,
|
||||
},
|
||||
dayCellLocked: { backgroundColor: '#F3F4F6' },
|
||||
dayCellCompleted: { backgroundColor: '#ECFDF5', borderWidth: 1, borderColor: '#A7F3D0' },
|
||||
dayNumber: { fontWeight: '800', color: '#111827', fontSize: 16 },
|
||||
dayNumberLocked: { color: '#9CA3AF' },
|
||||
dayMinutes: { marginTop: 4, fontSize: 12, color: '#6B7280' },
|
||||
bottomBar: { padding: 20 },
|
||||
startButton: { backgroundColor: Colors.light.accentGreen, paddingVertical: 14, borderRadius: 999, alignItems: 'center' },
|
||||
startButtonText: { color: '#192126', fontWeight: '800', fontSize: 16 },
|
||||
});
|
||||
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -30,8 +30,8 @@ import { api, getAuthToken, postTextStream } from '@/services/api';
|
||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
|
||||
import { Image } from 'expo-image';
|
||||
import { HistoryModal } from '../../components/model/HistoryModal';
|
||||
import { ActionSheet } from '../../components/ui/ActionSheet';
|
||||
import { HistoryModal } from '../components/model/HistoryModal';
|
||||
import { ActionSheet } from '../components/ui/ActionSheet';
|
||||
|
||||
// 导入新的 coach 组件
|
||||
import {
|
||||
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',
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,8 @@ 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,
|
||||
@@ -33,6 +35,8 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
// 配置 dayjs 插件
|
||||
dayjs.extend(utc);
|
||||
@@ -50,6 +54,8 @@ type WeekData = {
|
||||
};
|
||||
|
||||
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());
|
||||
@@ -80,7 +86,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
exerciseInfoAnim.setValue(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载锻炼分钟说明偏好失败:', error);
|
||||
console.error(t('fitnessRingsDetail.errors.loadExerciseInfoPreference'), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +102,15 @@ export default function FitnessRingsDetailScreen() {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const currentDay = startOfWeek.add(i, 'day');
|
||||
const isToday = currentDay.isSame(today, 'day');
|
||||
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
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());
|
||||
@@ -162,8 +176,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
|
||||
// 格式化头部显示的日期
|
||||
const formatHeaderDate = (date: Date) => {
|
||||
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
|
||||
return `${dayJsDate.format('YYYY年MM月DD日')}`;
|
||||
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) => {
|
||||
@@ -301,7 +316,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
setShowExerciseInfo(false);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存锻炼分钟说明偏好失败:', error);
|
||||
console.error(t('fitnessRingsDetail.errors.saveExerciseInfoPreference'), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -330,9 +345,11 @@ export default function FitnessRingsDetailScreen() {
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{
|
||||
flex: 1,
|
||||
height: value > 0 ? height : 2, // 没有数据时显示最小高度的灰色条
|
||||
backgroundColor: value > 0 ? color : '#E5E5EA',
|
||||
opacity: value > 0 ? 1 : 0.5
|
||||
opacity: value > 0 ? 1 : 0.5,
|
||||
marginHorizontal: 0.5
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -340,10 +357,19 @@ export default function FitnessRingsDetailScreen() {
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.chartLabels}>
|
||||
<Text style={styles.chartLabel}>00:00</Text>
|
||||
<Text style={styles.chartLabel}>06:00</Text>
|
||||
<Text style={styles.chartLabel}>12:00</Text>
|
||||
<Text style={styles.chartLabel}>18:00</Text>
|
||||
{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>
|
||||
);
|
||||
@@ -367,7 +393,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 活动热量卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>活动热量</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.activeCalories.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -377,25 +403,25 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
|
||||
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>千卡</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(activeEnergyBurned)}千卡
|
||||
{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}>锻炼分钟数</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -405,18 +431,18 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#FF9500' }]}>
|
||||
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>分钟</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleExerciseTime)}分钟
|
||||
{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')
|
||||
)}
|
||||
|
||||
{/* 锻炼分钟说明 */}
|
||||
@@ -437,15 +463,15 @@ export default function FitnessRingsDetailScreen() {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={styles.exerciseTitle}>锻炼分钟数:</Text>
|
||||
<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}>
|
||||
世卫组织推荐的成年人每天至少保持30分钟以上的中高强度运动。
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
|
||||
<Text style={styles.knowButtonText}>知道了</Text>
|
||||
<Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -454,7 +480,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 活动小时数卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>活动小时数</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -464,18 +490,18 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#007AFF' }]}>
|
||||
{Math.round(appleStandHours)}/{appleStandHoursGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>小时</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleStandHours)}小时
|
||||
{Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyStandData.map(h => h.hasStood),
|
||||
1,
|
||||
'#007AFF',
|
||||
'小时'
|
||||
t('fitnessRingsDetail.cards.standHours.unit')
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -500,7 +526,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 本周圆环横向滚动 */}
|
||||
@@ -521,9 +549,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 周闭环天数统计 */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>周闭环天数</Text>
|
||||
<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()}天</Text>
|
||||
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}{t('fitnessRingsDetail.stats.daysUnit')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -544,7 +572,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={new Date(2020, 0, 1)}
|
||||
maximumDate={new Date()}
|
||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
@@ -560,12 +588,12 @@ export default function FitnessRingsDetailScreen() {
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<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]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
@@ -731,23 +759,25 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-end',
|
||||
height: 60,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 4,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
chartBar: {
|
||||
width: 3,
|
||||
borderRadius: 1.5,
|
||||
marginHorizontal: 0.5,
|
||||
},
|
||||
chartLabels: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 2,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
chartLabel: {
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
color: '#8E8E93',
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
flex: 6, // 给显示标签的元素更多空间
|
||||
},
|
||||
chartLabelSpacer: {
|
||||
flex: 1, // 占位元素使用较少空间
|
||||
},
|
||||
// 锻炼信息样式
|
||||
exerciseInfo: {
|
||||
@@ -857,4 +887,4 @@ const styles = StyleSheet.create({
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
|
||||
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -64,15 +66,9 @@ const mockFoodItems = [
|
||||
}
|
||||
];
|
||||
|
||||
// 餐次映射
|
||||
const MEAL_TYPE_MAP = {
|
||||
breakfast: '早餐',
|
||||
lunch: '午餐',
|
||||
dinner: '晚餐',
|
||||
snack: '加餐'
|
||||
};
|
||||
|
||||
export default function FoodAnalysisResultScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
imageUri?: string;
|
||||
@@ -188,6 +184,15 @@ export default function FoodAnalysisResultScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 餐次映射
|
||||
const MEAL_TYPE_MAP = {
|
||||
breakfast: t('nutritionRecords.mealTypes.breakfast'),
|
||||
lunch: t('nutritionRecords.mealTypes.lunch'),
|
||||
dinner: t('nutritionRecords.mealTypes.dinner'),
|
||||
snack: t('nutritionRecords.mealTypes.snack'),
|
||||
other: t('nutritionRecords.mealTypes.other'),
|
||||
};
|
||||
|
||||
// 计算所有食物的总营养数据
|
||||
const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0);
|
||||
const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
|
||||
@@ -251,21 +256,24 @@ export default function FoodAnalysisResultScreen() {
|
||||
|
||||
// 餐次选择选项
|
||||
const mealOptions = [
|
||||
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
|
||||
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
|
||||
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
|
||||
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
|
||||
{ key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), color: '#FF6B35' },
|
||||
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), color: '#4CAF50' },
|
||||
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), color: '#2196F3' },
|
||||
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), color: '#FF9800' },
|
||||
];
|
||||
|
||||
if (!imageUri && !recognitionResult) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
title={t('foodAnalysisResult.title')}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||||
<Text style={styles.errorText}>{t('foodAnalysisResult.error.notFound')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -282,12 +290,14 @@ export default function FoodAnalysisResultScreen() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
title={t('foodAnalysisResult.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView style={styles.scrollContainer} contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}} showsVerticalScrollIndicator={false}>
|
||||
{/* 食物主图 */}
|
||||
<View style={styles.imageContainer}>
|
||||
{imageUri ? (
|
||||
@@ -309,7 +319,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.placeholderContainer}>
|
||||
<View style={styles.placeholderContent}>
|
||||
<Ionicons name="restaurant-outline" size={48} color="#666" />
|
||||
<Text style={styles.placeholderText}>营养记录</Text>
|
||||
<Text style={styles.placeholderText}>{t('foodAnalysisResult.placeholder')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -318,8 +328,8 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.descriptionBubble}>
|
||||
<Text style={styles.descriptionText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
dayjs().format('YYYY年M月D日')
|
||||
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
|
||||
dayjs().format(t('foodAnalysisResult.dateFormats.today'))
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -330,31 +340,31 @@ export default function FoodAnalysisResultScreen() {
|
||||
{/* 卡路里 */}
|
||||
<View style={styles.calorieSection}>
|
||||
<Text style={styles.calorieValue}>{totalCalories}</Text>
|
||||
<Text style={styles.calorieUnit}>千卡</Text>
|
||||
<Text style={styles.calorieUnit}>{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 营养圆环图 */}
|
||||
<View style={styles.nutritionRings}>
|
||||
<NutritionRing
|
||||
label="蛋白质"
|
||||
label={t('foodAnalysisResult.nutrients.protein')}
|
||||
value={totalProtein.toFixed(1)}
|
||||
unit="克"
|
||||
unit={t('foodAnalysisResult.nutrients.unit')}
|
||||
percentage={Math.min(100, proteinPercentage)}
|
||||
color="#4CAF50"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
<NutritionRing
|
||||
label="脂肪"
|
||||
label={t('foodAnalysisResult.nutrients.fat')}
|
||||
value={totalFat.toFixed(1)}
|
||||
unit="克"
|
||||
unit={t('foodAnalysisResult.nutrients.unit')}
|
||||
percentage={Math.min(100, fatPercentage)}
|
||||
color="#FF9800"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
<NutritionRing
|
||||
label="碳水"
|
||||
label={t('foodAnalysisResult.nutrients.carbs')}
|
||||
value={totalCarbohydrate.toFixed(1)}
|
||||
unit="克"
|
||||
unit={t('foodAnalysisResult.nutrients.unit')}
|
||||
percentage={Math.min(100, carbohydratePercentage)}
|
||||
color="#2196F3"
|
||||
resetToken={animationTrigger}
|
||||
@@ -365,7 +375,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
{/* 食物摄入部分 */}
|
||||
<View style={styles.foodIntakeSection}>
|
||||
<Text style={styles.foodIntakeTitle}>
|
||||
{recognitionResult ? '识别结果' : '食物摄入'}
|
||||
{recognitionResult ? t('foodAnalysisResult.sections.recognitionResult') : t('foodAnalysisResult.sections.foodIntake')}
|
||||
</Text>
|
||||
{recognitionResult && recognitionResult.analysisText && (
|
||||
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
|
||||
@@ -377,15 +387,15 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.nonFoodIcon}>
|
||||
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
|
||||
</View>
|
||||
<Text style={styles.nonFoodTitle}>未识别到食物</Text>
|
||||
<Text style={styles.nonFoodTitle}>{t('foodAnalysisResult.nonFood.title')}</Text>
|
||||
<Text style={styles.nonFoodMessage}>
|
||||
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
|
||||
</Text>
|
||||
<View style={styles.nonFoodSuggestions}>
|
||||
<Text style={styles.nonFoodSuggestionsTitle}>建议:</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>• 确保图片中包含食物</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>• 尝试更清晰的照片角度</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>• 避免过度模糊或光线不足</Text>
|
||||
<Text style={styles.nonFoodSuggestionsTitle}>{t('foodAnalysisResult.nonFood.suggestions.title')}</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item1')}</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item2')}</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item3')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -404,7 +414,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.foodIntakeCalories}>
|
||||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}千卡</Text>
|
||||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
|
||||
{shouldHideRecordBar ? null : <TouchableOpacity
|
||||
style={styles.editButton}
|
||||
onPress={() => handleEditFood(item)}
|
||||
@@ -435,7 +445,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retakePhotoButtonText}>重新拍照</Text>
|
||||
<Text style={styles.retakePhotoButtonText}>{t('foodAnalysisResult.actions.retake')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
@@ -464,7 +474,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
{isRecording ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
<Text style={styles.recordButtonText}>{t('foodAnalysisResult.actions.record')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -485,7 +495,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
/>
|
||||
<View style={styles.mealSelectorModal}>
|
||||
<View style={styles.mealSelectorHeader}>
|
||||
<Text style={styles.mealSelectorTitle}>选择餐次</Text>
|
||||
<Text style={styles.mealSelectorTitle}>{t('foodAnalysisResult.mealSelector.title')}</Text>
|
||||
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
@@ -532,8 +542,8 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
dayjs().format('YYYY年M月D日 HH:mm')
|
||||
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
|
||||
dayjs().format(t('foodAnalysisResult.dateFormats.full'))
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -544,7 +554,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('foodAnalysisResult.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -580,6 +590,8 @@ function FoodEditModal({
|
||||
onFormDataChange({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -591,14 +603,14 @@ function FoodEditModal({
|
||||
<View style={styles.editModalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
|
||||
<Text style={styles.modalTitle}>编辑食物信息</Text>
|
||||
<Text style={styles.modalTitle}>{t('foodAnalysisResult.editModal.title')}</Text>
|
||||
|
||||
{/* 食物名称 */}
|
||||
<View style={styles.editFieldContainer}>
|
||||
<Text style={styles.editFieldLabel}>食物名称</Text>
|
||||
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.name')}</Text>
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
placeholder="输入食物名称"
|
||||
placeholder={t('foodAnalysisResult.editModal.fields.namePlaceholder')}
|
||||
placeholderTextColor="#999"
|
||||
value={formData.name}
|
||||
onChangeText={(value) => handleFieldChange('name', value)}
|
||||
@@ -608,10 +620,10 @@ function FoodEditModal({
|
||||
|
||||
{/* 重量/数量 */}
|
||||
<View style={styles.editFieldContainer}>
|
||||
<Text style={styles.editFieldLabel}>重量 (克)</Text>
|
||||
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.amount')}</Text>
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
placeholder="输入重量"
|
||||
placeholder={t('foodAnalysisResult.editModal.fields.amountPlaceholder')}
|
||||
placeholderTextColor="#999"
|
||||
value={formData.amount}
|
||||
onChangeText={(value) => handleFieldChange('amount', value)}
|
||||
@@ -621,10 +633,10 @@ function FoodEditModal({
|
||||
|
||||
{/* 卡路里 */}
|
||||
<View style={styles.editFieldContainer}>
|
||||
<Text style={styles.editFieldLabel}>卡路里 (千卡)</Text>
|
||||
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.calories')}</Text>
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
placeholder="输入卡路里"
|
||||
placeholder={t('foodAnalysisResult.editModal.fields.caloriesPlaceholder')}
|
||||
placeholderTextColor="#999"
|
||||
value={formData.calories}
|
||||
onChangeText={(value) => handleFieldChange('calories', value)}
|
||||
@@ -638,13 +650,13 @@ function FoodEditModal({
|
||||
onPress={onClose}
|
||||
style={styles.modalCancelBtn}
|
||||
>
|
||||
<Text style={styles.modalCancelText}>取消</Text>
|
||||
<Text style={styles.modalCancelText}>{t('foodAnalysisResult.editModal.actions.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={onSave}
|
||||
style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>保存</Text>
|
||||
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>{t('foodAnalysisResult.editModal.actions.save')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -1,471 +0,0 @@
|
||||
import { GoalCard } from '@/components/GoalCard';
|
||||
import { CreateGoalModal } from '@/components/model/CreateGoalModal';
|
||||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { deleteGoal, fetchGoals, loadMoreGoals, updateGoal } from '@/store/goalsSlice';
|
||||
import { CreateGoalRequest, GoalListItem, UpdateGoalRequest } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function GoalsListScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
|
||||
const { showConfirm } = useGlobalDialog();
|
||||
|
||||
// Redux状态
|
||||
const {
|
||||
goals,
|
||||
goalsLoading,
|
||||
goalsError,
|
||||
goalsPagination,
|
||||
updateLoading,
|
||||
updateError,
|
||||
} = useAppSelector((state) => state.goals);
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 编辑目标相关状态
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<GoalListItem | null>(null);
|
||||
|
||||
// 页面聚焦时重新加载数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('useFocusEffect - loading goals');
|
||||
loadGoals();
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
// 加载目标列表
|
||||
const loadGoals = async () => {
|
||||
try {
|
||||
await dispatch(fetchGoals({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to load goals:', error);
|
||||
// 在开发模式下,如果API调用失败,使用模拟数据
|
||||
if (__DEV__) {
|
||||
console.log('Using mock data for development');
|
||||
// 添加模拟数据用于测试左滑删除功能
|
||||
const mockGoals: GoalListItem[] = [
|
||||
{
|
||||
id: 'mock-1',
|
||||
userId: 'test-user-1',
|
||||
title: '每日运动30分钟',
|
||||
repeatType: 'daily',
|
||||
frequency: 1,
|
||||
status: 'active',
|
||||
completedCount: 5,
|
||||
targetCount: 30,
|
||||
hasReminder: true,
|
||||
reminderTime: '09:00',
|
||||
category: '运动',
|
||||
priority: 5,
|
||||
startDate: '2024-01-01',
|
||||
startTime: 900,
|
||||
endTime: 1800,
|
||||
progressPercentage: 17,
|
||||
},
|
||||
{
|
||||
id: 'mock-2',
|
||||
userId: 'test-user-1',
|
||||
title: '每天喝8杯水',
|
||||
repeatType: 'daily',
|
||||
frequency: 8,
|
||||
status: 'active',
|
||||
completedCount: 6,
|
||||
targetCount: 8,
|
||||
hasReminder: true,
|
||||
reminderTime: '10:00',
|
||||
category: '健康',
|
||||
priority: 8,
|
||||
startDate: '2024-01-01',
|
||||
startTime: 600,
|
||||
endTime: 2200,
|
||||
progressPercentage: 75,
|
||||
},
|
||||
{
|
||||
id: 'mock-3',
|
||||
userId: 'test-user-1',
|
||||
title: '每周读书2小时',
|
||||
repeatType: 'weekly',
|
||||
frequency: 2,
|
||||
status: 'paused',
|
||||
completedCount: 1,
|
||||
targetCount: 2,
|
||||
hasReminder: false,
|
||||
category: '学习',
|
||||
priority: 3,
|
||||
startDate: '2024-01-01',
|
||||
startTime: 800,
|
||||
endTime: 2000,
|
||||
progressPercentage: 50,
|
||||
},
|
||||
];
|
||||
|
||||
// 直接更新 Redux 状态(仅用于开发测试)
|
||||
dispatch({
|
||||
type: 'goals/fetchGoals/fulfilled',
|
||||
payload: {
|
||||
query: { page: 1, pageSize: 20, sortBy: 'createdAt', sortOrder: 'desc' },
|
||||
response: {
|
||||
list: mockGoals,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: mockGoals.length,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadGoals();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多目标
|
||||
const handleLoadMoreGoals = async () => {
|
||||
if (goalsPagination.hasMore && !goalsLoading) {
|
||||
try {
|
||||
await dispatch(loadMoreGoals()).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to load more goals:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除目标
|
||||
const handleDeleteGoal = async (goalId: string) => {
|
||||
try {
|
||||
await dispatch(deleteGoal(goalId)).unwrap();
|
||||
// 删除成功,Redux 会自动更新状态
|
||||
} catch (error) {
|
||||
console.error('Failed to delete goal:', error);
|
||||
Alert.alert('错误', '删除目标失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理错误提示
|
||||
useEffect(() => {
|
||||
if (goalsError) {
|
||||
Alert.alert('错误', goalsError);
|
||||
}
|
||||
if (updateError) {
|
||||
Alert.alert('更新失败', updateError);
|
||||
}
|
||||
}, [goalsError, updateError]);
|
||||
|
||||
|
||||
// 根据筛选条件过滤目标
|
||||
const filteredGoals = useMemo(() => {
|
||||
return goals;
|
||||
}, [goals]);
|
||||
|
||||
|
||||
|
||||
// 处理目标点击
|
||||
const handleGoalPress = (goal: GoalListItem) => {
|
||||
setEditingGoal(goal);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// 将 GoalListItem 转换为 CreateGoalRequest 格式
|
||||
const convertGoalToModalData = (goal: GoalListItem): Partial<CreateGoalRequest> => {
|
||||
return {
|
||||
title: goal.title,
|
||||
description: goal.description,
|
||||
repeatType: goal.repeatType,
|
||||
frequency: goal.frequency,
|
||||
category: goal.category,
|
||||
priority: goal.priority,
|
||||
hasReminder: goal.hasReminder,
|
||||
reminderTime: goal.reminderTime,
|
||||
customRepeatRule: goal.customRepeatRule,
|
||||
endDate: goal.endDate,
|
||||
};
|
||||
};
|
||||
|
||||
// 处理更新目标
|
||||
const handleUpdateGoal = async (goalId: string, goalData: UpdateGoalRequest) => {
|
||||
try {
|
||||
await dispatch(updateGoal({ goalId, goalData })).unwrap();
|
||||
setShowEditModal(false);
|
||||
setEditingGoal(null);
|
||||
|
||||
// 使用全局弹窗显示成功消息
|
||||
showConfirm(
|
||||
{
|
||||
title: '目标更新成功',
|
||||
message: '恭喜!您的目标已成功更新。',
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
icon: 'checkmark-circle',
|
||||
iconColor: '#10B981',
|
||||
},
|
||||
() => {
|
||||
console.log('用户确认了目标更新成功');
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to update goal:', error);
|
||||
Alert.alert('错误', '更新目标失败,请重试');
|
||||
// 更新失败时不关闭弹窗,保持编辑状态
|
||||
}
|
||||
};
|
||||
|
||||
// 处理编辑弹窗关闭
|
||||
const handleCloseEditModal = () => {
|
||||
setShowEditModal(false);
|
||||
setEditingGoal(null);
|
||||
};
|
||||
|
||||
// 渲染目标项
|
||||
const renderGoalItem = ({ item }: { item: GoalListItem }) => (
|
||||
<GoalCard
|
||||
goal={item}
|
||||
onPress={handleGoalPress}
|
||||
onDelete={handleDeleteGoal}
|
||||
showStatus={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
let title = '暂无目标';
|
||||
let subtitle = '创建您的第一个目标,开始您的健康之旅';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<MaterialIcons name="flag" size={64} color="#D1D5DB" />
|
||||
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.createButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={() => router.push('/(tabs)/goals')}
|
||||
>
|
||||
<Text style={styles.createButtonText}>创建目标</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 渲染加载更多
|
||||
const renderLoadMore = () => {
|
||||
if (!goalsPagination.hasMore) return null;
|
||||
return (
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||||
{goalsLoading ? '加载中...' : '上拉加载更多'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#F8FAFC', '#F1F5F9']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={24} color={colorTokens.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.pageTitle, { color: colorTokens.text }]}>
|
||||
目标列表
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => router.push('/(tabs)/goals')}
|
||||
>
|
||||
<MaterialIcons name="add" size={24} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
{/* 目标列表 */}
|
||||
<View style={styles.goalsListContainer}>
|
||||
<FlatList
|
||||
data={filteredGoals}
|
||||
renderItem={renderGoalItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.goalsList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#7A5AF8']}
|
||||
tintColor="#7A5AF8"
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMoreGoals}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderLoadMore}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 编辑目标弹窗 */}
|
||||
{editingGoal && (
|
||||
<CreateGoalModal
|
||||
visible={showEditModal}
|
||||
onClose={handleCloseEditModal}
|
||||
onSubmit={() => {}} // 编辑模式下不使用这个回调
|
||||
onUpdate={handleUpdateGoal}
|
||||
loading={updateLoading}
|
||||
initialData={convertGoalToModalData(editingGoal)}
|
||||
editGoalId={editingGoal.id}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
addButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#7A5AF8',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
goalsListContainer: {
|
||||
flex: 1,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
goalsList: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 80,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateSubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
createButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
createButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadMoreContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
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',
|
||||
},
|
||||
});
|
||||