Compare commits
80 Commits
feature/ch
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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 文档**: 接口文档
|
||||
- **组件文档**: 组件说明
|
||||
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
|
||||
- 总是先总结方案,等我确认之后,再进行实现
|
||||
13
app.json
@@ -2,11 +2,11 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.15",
|
||||
"version": "1.0.20",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"icon": "./assets/logo.png",
|
||||
"newArchEnabled": true,
|
||||
"jsEngine": "jsc",
|
||||
"ios": {
|
||||
@@ -21,6 +21,11 @@
|
||||
"NSMicrophoneUsageDescription": "应用需要使用麦克风进行语音识别,将您的语音转换为文字记录饮食信息。",
|
||||
"NSSpeechRecognitionUsageDescription": "应用需要使用语音识别功能来转换您的语音为文字,帮助您快速记录饮食信息。",
|
||||
"NSUserNotificationsUsageDescription": "应用需要发送通知以提醒您喝水和站立活动。",
|
||||
"BGTaskSchedulerPermittedIdentifiers": [
|
||||
"com.expo.modules.backgroundtask.processing",
|
||||
"com.anonymous.digitalpilates.task",
|
||||
"com.anonymous.digitalpilates.processing"
|
||||
],
|
||||
"UIBackgroundModes": [
|
||||
"processing",
|
||||
"fetch",
|
||||
@@ -34,7 +39,7 @@
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"image": "./assets/logo.png",
|
||||
"imageWidth": 40,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
@@ -43,7 +48,7 @@
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.icon/Assets/icon-1756312748268.jpg",
|
||||
"icon": "./assets/logo.png",
|
||||
"color": "#ffffff"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GlassContainer, GlassView, isLiquidGlassAvailable } from 'expo-glass-ef
|
||||
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';
|
||||
@@ -11,32 +12,40 @@ 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: '健康' },
|
||||
goals: { icon: 'flag.fill', title: '习惯' },
|
||||
challenges: { icon: 'trophy.fill', title: '挑战' },
|
||||
personal: { icon: 'person.fill', title: '个人' },
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
|
||||
};
|
||||
|
||||
export default function TabLayout() {
|
||||
const { t } = useTranslation();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const pathname = usePathname();
|
||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取已启用的标签配置(按自定义顺序)
|
||||
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
statistics: ROUTES.TAB_STATISTICS,
|
||||
goals: ROUTES.TAB_GOALS,
|
||||
medications: ROUTES.TAB_MEDICATIONS,
|
||||
fasting: ROUTES.TAB_FASTING,
|
||||
challenges: ROUTES.TAB_CHALLENGES,
|
||||
personal: ROUTES.TAB_PERSONAL,
|
||||
};
|
||||
@@ -90,11 +99,11 @@ export default function TabLayout() {
|
||||
color: colorTokens.tabIconSelected,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
marginLeft: 6
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tabConfig.title}
|
||||
{t(tabConfig.titleKey)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -170,37 +179,45 @@ export default function TabLayout() {
|
||||
tabBarShowLabel: false,
|
||||
});
|
||||
|
||||
// 根据配置渲染标签页
|
||||
if (glassEffectAvailable) {
|
||||
return <NativeTabs>
|
||||
<NativeTabs.Trigger name="statistics">
|
||||
<Label>健康</Label>
|
||||
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="goals">
|
||||
<Icon sf="flag.fill" drawable="custom_settings_drawable" />
|
||||
<Label>习惯</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="challenges">
|
||||
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
|
||||
<Label>挑战</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="personal">
|
||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
||||
<Label>我的</Label>
|
||||
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)}
|
||||
>
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: '健康' }} />
|
||||
<Tabs.Screen name="goals" options={{ title: '习惯' }} />
|
||||
<Tabs.Screen name="challenges" options={{ title: '挑战' }} />
|
||||
<Tabs.Screen name="personal" options={{ title: '个人' }} />
|
||||
return (
|
||||
<Tabs.Screen
|
||||
key={tab.id}
|
||||
name={tab.id}
|
||||
options={{ title: t(tabConfig.titleKey) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
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 } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -22,6 +34,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions
|
||||
@@ -31,11 +44,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
const AVATAR_SIZE = 36;
|
||||
const CARD_IMAGE_WIDTH = 132;
|
||||
const CARD_IMAGE_HEIGHT = 96;
|
||||
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
||||
upcoming: '即将开始',
|
||||
ongoing: '进行中',
|
||||
expired: '已结束',
|
||||
};
|
||||
|
||||
const CAROUSEL_ITEM_SPACING = 16;
|
||||
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
||||
@@ -44,16 +52,32 @@ 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 challenges = useAppSelector(selectChallengeCards);
|
||||
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 challenges.filter((challenge) => {
|
||||
return allChallenges.filter((challenge) => {
|
||||
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
||||
return false;
|
||||
}
|
||||
@@ -67,7 +91,7 @@ export default function ChallengesScreen() {
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [challenges]);
|
||||
}, [allChallenges]);
|
||||
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
||||
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
||||
|
||||
@@ -82,42 +106,92 @@ export default function ChallengesScreen() {
|
||||
? ['#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' && challenges.length === 0) {
|
||||
if (listStatus === 'loading' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (listStatus === 'failed' && challenges.length === 0) {
|
||||
if (listStatus === 'failed' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||
{listError ?? '加载挑战失败,请稍后重试'}
|
||||
{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 }]}>重新加载</Text>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (challenges.length === 0) {
|
||||
if (customChallenges.length === 0 && officialChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return challenges.map((challenge) => (
|
||||
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}
|
||||
@@ -128,7 +202,36 @@ export default function ChallengesScreen() {
|
||||
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 (
|
||||
@@ -143,19 +246,42 @@ export default function ChallengesScreen() {
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>{t('challenges.title')}</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
|
||||
</View>
|
||||
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.giftButton}
|
||||
<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
|
||||
>
|
||||
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity> */}
|
||||
<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 ? (
|
||||
@@ -172,6 +298,34 @@ export default function ChallengesScreen() {
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +339,8 @@ type ChallengeCardProps = {
|
||||
};
|
||||
|
||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
|
||||
const { t } = useI18n();
|
||||
const statusLabel = t(`challenges.statusLabels.${challenge.status}`) ?? challenge.status;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -235,7 +390,7 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
||||
style={[styles.cardParticipants, { color: mutedColor }]}
|
||||
>
|
||||
{challenge.participantsLabel}
|
||||
{challenge.isJoined ? ' · 已加入' : ''}
|
||||
{challenge.isJoined ? ` · ${t('challenges.joined')}` : ''}
|
||||
</Text>
|
||||
{challenge.avatars.length ? (
|
||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||
@@ -325,7 +480,7 @@ function OngoingChallengesCarousel({
|
||||
>
|
||||
<ChallengeProgressCard
|
||||
title={item.title}
|
||||
endAt={item.endAt}
|
||||
endAt={item.endAt as string}
|
||||
progress={item.progress}
|
||||
style={styles.carouselProgressCard}
|
||||
backgroundColors={[colorTokens.card, colorTokens.card]}
|
||||
@@ -450,31 +605,79 @@ const styles = StyleSheet.create({
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
giftShadow: {
|
||||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
borderRadius: 26,
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
giftButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 26,
|
||||
joinButtonGlass: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
createButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
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,
|
||||
},
|
||||
@@ -555,16 +758,19 @@ const styles = StyleSheet.create({
|
||||
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,
|
||||
@@ -594,6 +800,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#f7f9ff',
|
||||
letterSpacing: 0.3,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
cardProgress: {
|
||||
marginTop: 8,
|
||||
@@ -614,4 +821,25 @@ const styles = StyleSheet.create({
|
||||
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,920 +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 isLoggedIn', isLoggedIn);
|
||||
|
||||
if (isLoggedIn) {
|
||||
loadTasks();
|
||||
checkAndShowGuide();
|
||||
}
|
||||
}, [dispatch, isLoggedIn])
|
||||
);
|
||||
|
||||
// 检查并显示用户引导
|
||||
const checkAndShowGuide = async () => {
|
||||
try {
|
||||
const hasCompletedGuide = await checkGuideCompleted('GOALS_PAGE');
|
||||
if (!hasCompletedGuide) {
|
||||
// 延迟显示引导,确保页面完全加载
|
||||
setTimeout(() => {
|
||||
setShowGuide(true);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查引导状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
|
||||
await dispatch(fetchTasks({
|
||||
startDate: dayjs().startOf('day').toISOString(),
|
||||
endDate: dayjs().endOf('day').toISOString(),
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
await loadTasks();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多任务
|
||||
const handleLoadMoreTasks = async () => {
|
||||
if (!isLoggedIn) return
|
||||
|
||||
if (tasksPagination.hasMore && !tasksLoading) {
|
||||
try {
|
||||
await dispatch(loadMoreTasks()).unwrap();
|
||||
} catch (error) {
|
||||
console.error('Failed to load more tasks:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理错误提示
|
||||
useEffect(() => {
|
||||
|
||||
if (tasksError) {
|
||||
Alert.alert('错误', tasksError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
if (createError) {
|
||||
Alert.alert('创建失败', createError);
|
||||
dispatch(clearErrors());
|
||||
}
|
||||
if (completeError) {
|
||||
Alert.alert('完成失败', completeError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
if (skipError) {
|
||||
Alert.alert('跳过失败', skipError);
|
||||
dispatch(clearTaskErrors());
|
||||
}
|
||||
}, [tasksError, createError, completeError, skipError, dispatch]);
|
||||
|
||||
// 重置弹窗表单数据
|
||||
const handleModalSuccess = () => {
|
||||
// 不需要在这里改变 modalKey,因为弹窗已经关闭了
|
||||
// 下次打开时会自动使用新的 modalKey
|
||||
setSelectedTemplateData(undefined);
|
||||
};
|
||||
|
||||
// 处理模板选择
|
||||
const handleSelectTemplate = (template: GoalTemplate) => {
|
||||
setSelectedTemplateData(template.data);
|
||||
setShowTemplateModal(false);
|
||||
setModalKey(prev => prev + 1);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// 处理创建自定义目标
|
||||
const handleCreateCustomGoal = () => {
|
||||
setSelectedTemplateData(undefined);
|
||||
setShowTemplateModal(false);
|
||||
setModalKey(prev => prev + 1);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
// 打开模板选择弹窗
|
||||
const handleOpenTemplateModal = () => {
|
||||
setSelectedTemplateData(undefined);
|
||||
setShowTemplateModal(true);
|
||||
};
|
||||
|
||||
// 创建目标处理函数
|
||||
const handleCreateGoal = async (goalData: CreateGoalRequest) => {
|
||||
try {
|
||||
await dispatch(createGoal(goalData)).unwrap();
|
||||
setShowCreateModal(false);
|
||||
|
||||
// 获取用户名
|
||||
const userName = userProfile?.name || '主人';
|
||||
|
||||
// 创建目标成功后,设置定时推送
|
||||
try {
|
||||
if (goalData.hasReminder) {
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: goalData.title,
|
||||
repeatType: goalData.repeatType,
|
||||
frequency: goalData.frequency,
|
||||
hasReminder: goalData.hasReminder,
|
||||
reminderTime: goalData.reminderTime,
|
||||
customRepeatRule: goalData.customRepeatRule,
|
||||
startTime: goalData.startTime,
|
||||
},
|
||||
userName
|
||||
);
|
||||
console.log(`目标"${goalData.title}"的定时推送已创建,通知ID:`, notificationIds);
|
||||
}
|
||||
|
||||
} catch (notificationError) {
|
||||
console.error('创建目标定时推送失败:', notificationError);
|
||||
// 通知创建失败不影响目标创建的成功
|
||||
}
|
||||
|
||||
// 使用确认弹窗显示成功消息
|
||||
showConfirm(
|
||||
{
|
||||
title: '目标创建成功',
|
||||
message: '恭喜!您的目标已成功创建。系统将自动生成相应的任务,帮助您实现目标。',
|
||||
confirmText: '确定',
|
||||
cancelText: '',
|
||||
icon: 'checkmark-circle',
|
||||
iconColor: '#10B981',
|
||||
},
|
||||
() => {
|
||||
// 用户点击确定后的回调
|
||||
console.log('用户确认了目标创建成功');
|
||||
}
|
||||
);
|
||||
|
||||
// 创建目标后重新加载任务列表
|
||||
loadTasks();
|
||||
} catch (error) {
|
||||
// 错误已在useEffect中处理
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 导航到任务列表页面
|
||||
const handleNavigateToTasks = () => {
|
||||
pushIfAuthedElseLogin('/task-list');
|
||||
};
|
||||
|
||||
// 计算各状态的任务数量
|
||||
const taskCounts = {
|
||||
all: tasks.length,
|
||||
pending: tasks.filter(task => task.status === 'pending').length,
|
||||
completed: tasks.filter(task => task.status === 'completed').length,
|
||||
skipped: tasks.filter(task => task.status === 'skipped').length,
|
||||
};
|
||||
|
||||
// 根据筛选条件过滤任务,并将已完成的任务放到最后
|
||||
const filteredTasks = React.useMemo(() => {
|
||||
let filtered: TaskListItem[] = [];
|
||||
|
||||
switch (selectedFilter) {
|
||||
case 'pending':
|
||||
filtered = tasks.filter(task => task.status === 'pending');
|
||||
break;
|
||||
case 'completed':
|
||||
filtered = tasks.filter(task => task.status === 'completed');
|
||||
break;
|
||||
case 'skipped':
|
||||
filtered = tasks.filter(task => task.status === 'skipped');
|
||||
break;
|
||||
default:
|
||||
filtered = tasks;
|
||||
break;
|
||||
}
|
||||
|
||||
// 对所有筛选结果进行排序:已完成的任务放到最后
|
||||
return [...filtered].sort((a, b) => {
|
||||
// 如果a已完成而b未完成,a排在后面
|
||||
if (a.status === 'completed' && b.status !== 'completed') {
|
||||
return 1;
|
||||
}
|
||||
// 如果b已完成而a未完成,b排在后面
|
||||
if (b.status === 'completed' && a.status !== 'completed') {
|
||||
return -1;
|
||||
}
|
||||
// 如果都已完成或都未完成,保持原有顺序
|
||||
return 0;
|
||||
});
|
||||
}, [tasks, selectedFilter]);
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = (filter: TaskFilterType) => {
|
||||
setSelectedFilter(filter);
|
||||
};
|
||||
|
||||
// 处理引导完成
|
||||
const handleGuideComplete = async () => {
|
||||
try {
|
||||
await markGuideCompleted('GOALS_PAGE');
|
||||
setShowGuide(false);
|
||||
} catch (error) {
|
||||
console.error('保存引导状态失败:', error);
|
||||
setShowGuide(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理任务完成
|
||||
const handleTaskCompleted = (completedTask: TaskListItem) => {
|
||||
// 触发震动反馈
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
}
|
||||
|
||||
// 播放庆祝动画
|
||||
celebrationAnimationRef.current?.play();
|
||||
|
||||
console.log(`任务 "${completedTask.title}" 已完成,播放庆祝动画`);
|
||||
};
|
||||
|
||||
// 渲染任务项
|
||||
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||
<TaskCard
|
||||
task={item}
|
||||
onTaskCompleted={handleTaskCompleted}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
// 未登录状态下的引导
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<View style={styles.emptyStateLogin}>
|
||||
<LinearGradient
|
||||
colors={['#F0F9FF', '#FEFEFE', '#F0F9FF']}
|
||||
style={styles.emptyStateLoginBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.emptyStateLoginContent}>
|
||||
{/* 清新的图标设计 */}
|
||||
<View style={styles.emptyStateLoginIconContainer}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, '#9B8AFB']}
|
||||
style={styles.emptyStateLoginIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<MaterialIcons name="person-outline" size={32} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
{/* 主标题 */}
|
||||
<Text style={[styles.emptyStateLoginTitle, { color: colorTokens.text }]}>
|
||||
开启您的健康之旅
|
||||
</Text>
|
||||
|
||||
{/* 副标题 */}
|
||||
<Text style={[styles.emptyStateLoginSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
登录后即可创建个人目标,让我们一起建立健康的生活习惯
|
||||
</Text>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.emptyStateLoginButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={() => pushIfAuthedElseLogin('/goals')}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, '#9B8AFB']}
|
||||
style={styles.emptyStateLoginButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Text style={styles.emptyStateLoginButtonText}>立即登录</Text>
|
||||
<MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 已登录但无任务的状态
|
||||
let title = '暂无任务';
|
||||
let subtitle = '创建目标后,系统会自动生成相应的任务';
|
||||
|
||||
if (selectedFilter === 'pending') {
|
||||
title = '暂无待完成的任务';
|
||||
subtitle = '当前没有待完成的任务';
|
||||
} else if (selectedFilter === 'completed') {
|
||||
title = '暂无已完成的任务';
|
||||
subtitle = '完成一些任务后,它们会显示在这里';
|
||||
} else if (selectedFilter === 'skipped') {
|
||||
title = '暂无已跳过的任务';
|
||||
subtitle = '跳过一些任务后,它们会显示在这里';
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/ImageEmpty.png')}
|
||||
style={styles.emptyStateImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染加载更多
|
||||
const renderLoadMore = () => {
|
||||
if (!tasksPagination.hasMore) return null;
|
||||
return (
|
||||
<View style={styles.loadMoreContainer}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.textSecondary }]}>
|
||||
{tasksLoading ? '加载中...' : '上拉加载更多'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#F0F9FF', '#E0F2FE']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#7A5AF8',
|
||||
height: 233,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
}}>
|
||||
{/* 右下角Lottie动画 */}
|
||||
<Lottie
|
||||
source={require('@/assets/lottie/Goal.json')}
|
||||
style={styles.bottomRightImage}
|
||||
autoPlay
|
||||
loop
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 标题区域 */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
|
||||
习惯养成
|
||||
</Text>
|
||||
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
|
||||
自律让我更健康
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 任务进度卡片 */}
|
||||
<View >
|
||||
<TaskProgressCard
|
||||
tasks={tasks}
|
||||
headerButtons={
|
||||
<View style={styles.cardHeaderButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.cardGoalsButton, { borderColor: colorTokens.primary }]}
|
||||
onPress={handleNavigateToTasks}
|
||||
>
|
||||
<Text style={[styles.cardGoalsButtonText, { color: colorTokens.primary }]}>
|
||||
历史
|
||||
</Text>
|
||||
<MaterialIcons name="list" size={16} color={colorTokens.primary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
||||
onPress={handleOpenTemplateModal}
|
||||
>
|
||||
<Text style={styles.cardAddButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 任务筛选标签 */}
|
||||
<TaskFilterTabs
|
||||
selectedFilter={selectedFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
taskCounts={taskCounts}
|
||||
/>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<View style={styles.taskListContainer}>
|
||||
<FlatList
|
||||
data={filteredTasks}
|
||||
renderItem={renderTaskItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.taskList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#0EA5E9']}
|
||||
tintColor="#0EA5E9"
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMoreTasks}
|
||||
onEndReachedThreshold={0.1}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderLoadMore}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 目标模板选择弹窗 */}
|
||||
<GoalTemplateModal
|
||||
visible={showTemplateModal}
|
||||
onClose={() => setShowTemplateModal(false)}
|
||||
onSelectTemplate={handleSelectTemplate}
|
||||
onCreateCustom={handleCreateCustomGoal}
|
||||
/>
|
||||
|
||||
{/* 创建目标弹窗 */}
|
||||
<CreateGoalModal
|
||||
key={modalKey}
|
||||
visible={showCreateModal}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateData(undefined);
|
||||
}}
|
||||
onSubmit={handleCreateGoal}
|
||||
onSuccess={handleModalSuccess}
|
||||
loading={createLoading}
|
||||
initialData={selectedTemplateData}
|
||||
/>
|
||||
|
||||
{/* 目标页面引导 */}
|
||||
<GoalsPageGuide
|
||||
visible={showGuide}
|
||||
onComplete={handleGuideComplete}
|
||||
tasks={tasks}
|
||||
/>
|
||||
|
||||
{/* 开发测试按钮 */}
|
||||
<GuideTestButton visible={__DEV__} />
|
||||
|
||||
{/* 目标通知测试按钮 */}
|
||||
{__DEV__ && (
|
||||
<TouchableOpacity
|
||||
style={styles.testButton}
|
||||
onPress={() => {
|
||||
// 这里可以导航到测试页面或显示测试弹窗
|
||||
Alert.alert(
|
||||
'目标通知测试',
|
||||
'选择要测试的通知类型',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '每日目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每日运动目标',
|
||||
repeatType: 'daily',
|
||||
frequency: 1,
|
||||
hasReminder: true,
|
||||
reminderTime: '09:00',
|
||||
},
|
||||
userName
|
||||
);
|
||||
Alert.alert('成功', `每日目标通知已创建,ID: ${notificationIds.join(', ')}`);
|
||||
} catch (error) {
|
||||
Alert.alert('错误', `创建通知失败: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '每周目标通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '';
|
||||
const notificationIds = await GoalNotificationHelpers.scheduleGoalNotifications(
|
||||
{
|
||||
title: '每周运动目标',
|
||||
repeatType: 'weekly',
|
||||
frequency: 1,
|
||||
hasReminder: true,
|
||||
reminderTime: '10:00',
|
||||
customRepeatRule: {
|
||||
weekdays: [1, 3, 5], // 周一、三、五
|
||||
},
|
||||
},
|
||||
userName
|
||||
);
|
||||
Alert.alert('成功', `每周目标通知已创建,ID: ${notificationIds.join(', ')}`);
|
||||
} catch (error) {
|
||||
Alert.alert('错误', `创建通知失败: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '目标达成通知',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const userName = userProfile?.name || '';
|
||||
await GoalNotificationHelpers.sendGoalAchievementNotification(userName, '每日运动目标');
|
||||
Alert.alert('成功', '目标达成通知已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', `发送通知失败: ${error}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: '测试庆祝动画',
|
||||
onPress: () => {
|
||||
celebrationAnimationRef.current?.play();
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.testButtonText}>测试通知</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 庆祝动画组件 */}
|
||||
<CelebrationAnimation ref={celebrationAnimationRef} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.6,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
goalsButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
pageTitle2: {
|
||||
fontSize: 16,
|
||||
fontWeight: '400',
|
||||
color: '#FFFFFF',
|
||||
lineHeight: 24,
|
||||
},
|
||||
addButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
lineHeight: 22,
|
||||
},
|
||||
|
||||
taskListContainer: {
|
||||
flex: 1,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
taskList: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20, // 避让底部导航栏 + 额外间距
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyStateImage: {
|
||||
width: 223,
|
||||
height: 59,
|
||||
marginBottom: 20,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateSubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
// 未登录空状态样式
|
||||
emptyStateLogin: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 80,
|
||||
position: 'relative',
|
||||
},
|
||||
emptyStateLoginBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 24,
|
||||
},
|
||||
emptyStateLoginContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
emptyStateLoginIconContainer: {
|
||||
marginBottom: 24,
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
emptyStateLoginIconGradient: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyStateLoginTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
emptyStateLoginSubtitle: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
emptyStateLoginButton: {
|
||||
borderRadius: 28,
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
},
|
||||
emptyStateLoginButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 28,
|
||||
gap: 8,
|
||||
},
|
||||
emptyStateLoginButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
loadMoreContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
bottomRightImage: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 36,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
// 任务进度卡片中的按钮样式
|
||||
cardHeaderButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
cardGoalsButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardGoalsListButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardGoalsButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
cardAddButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardAddButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
lineHeight: 18,
|
||||
},
|
||||
testButton: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
right: 20,
|
||||
backgroundColor: '#10B981',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
zIndex: 1000,
|
||||
},
|
||||
testButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
601
app/(tabs)/medications.tsx
Normal file
@@ -0,0 +1,601 @@
|
||||
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 { 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);
|
||||
|
||||
// 从 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 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}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenMedicationManagement}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleAddMedication}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
</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: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
welcome: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
sectionSpacing: {
|
||||
gap: 16,
|
||||
},
|
||||
dateSelectorContainer: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
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',
|
||||
},
|
||||
segmentBadge: {
|
||||
minWidth: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
segmentBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
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',
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
primaryButton: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 22,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardsWrapper: {
|
||||
gap: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 48,
|
||||
borderRadius: 24,
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -10,19 +10,24 @@ import StepsCard from '@/components/StepsCard';
|
||||
import { StressMeter } from '@/components/StressMeter';
|
||||
import WaterIntakeCard from '@/components/WaterIntakeCard';
|
||||
import { WeightHistoryCard } from '@/components/weight/WeightHistoryCard';
|
||||
import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AppState,
|
||||
Image,
|
||||
@@ -54,7 +59,9 @@ const FloatingCard = ({ children, 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 { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
@@ -270,8 +277,44 @@ export default function ExploreScreen() {
|
||||
}
|
||||
}, [executeLoadAllData, debouncedLoadAllData]);
|
||||
|
||||
// 同步 HealthKit 数据到服务端(带智能 diff 比较)
|
||||
const syncHealthDataToServer = React.useCallback(async () => {
|
||||
if (!isLoggedIn || !userProfile) {
|
||||
logger.info('用户未登录,跳过 HealthKit 数据同步');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
|
||||
|
||||
// 传入当前用户资料,用于 diff 比较
|
||||
const success = await syncHealthKitToServer(
|
||||
async (data) => {
|
||||
await dispatch(updateUserProfile(data) as any);
|
||||
},
|
||||
userProfile // 传入当前用户资料进行比较
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logger.info('HealthKit 数据同步到服务端成功');
|
||||
} else {
|
||||
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('同步 HealthKit 数据到服务端失败:', error);
|
||||
}
|
||||
}, [isLoggedIn, dispatch, userProfile]);
|
||||
|
||||
// 初始加载时执行数据加载和同步
|
||||
useEffect(() => {
|
||||
loadAllData(currentSelectedDate);
|
||||
|
||||
// 延迟1秒后执行同步,避免影响初始加载性能
|
||||
const syncTimer = setTimeout(() => {
|
||||
syncHealthDataToServer();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(syncTimer);
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -319,7 +362,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 }}
|
||||
@@ -343,14 +386,14 @@ export default function ExploreScreen() {
|
||||
<View style={styles.headerContent}>
|
||||
{/* 左边logo */}
|
||||
<Image
|
||||
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
||||
source={require('@/assets/machine.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 右边文字区域 */}
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>Out Live</Text>
|
||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 开发环境调试按钮 */}
|
||||
@@ -359,7 +402,7 @@ export default function ExploreScreen() {
|
||||
<TouchableOpacity
|
||||
style={styles.debugButton}
|
||||
onPress={async () => {
|
||||
console.log('🔧 手动触发后台任务测试...');
|
||||
console.log('🔧 Manual background task test...');
|
||||
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
||||
}}
|
||||
>
|
||||
@@ -369,7 +412,7 @@ export default function ExploreScreen() {
|
||||
<TouchableOpacity
|
||||
style={[styles.debugButton, styles.hrvTestButton]}
|
||||
onPress={async () => {
|
||||
console.log('🫀 测试HRV数据获取...');
|
||||
console.log('🫀 Testing HRV data fetch...');
|
||||
await testHRVDataFetch();
|
||||
}}
|
||||
>
|
||||
@@ -398,7 +441,16 @@ export default function ExploreScreen() {
|
||||
resetToken={animToken}
|
||||
/>
|
||||
|
||||
<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}>
|
||||
@@ -422,7 +474,6 @@ export default function ExploreScreen() {
|
||||
</FloatingCard>
|
||||
|
||||
|
||||
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<StressMeter
|
||||
curDate={currentSelectedDate}
|
||||
@@ -480,6 +531,7 @@ export default function ExploreScreen() {
|
||||
|
||||
</View>
|
||||
</View>
|
||||
<WeightHistoryCard />
|
||||
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<CircumferenceCard style={styles.circumferenceCard} />
|
||||
@@ -546,6 +598,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
debugButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -573,15 +626,6 @@ const styles = StyleSheet.create({
|
||||
debugButtonText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginTop: 24,
|
||||
marginBottom: 14,
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
@@ -817,6 +861,9 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
height: '100%', // 填充整个masonryCard
|
||||
},
|
||||
workoutCardOverride: {
|
||||
marginTop: 16,
|
||||
},
|
||||
waterCardOverride: {
|
||||
margin: -16, // 抵消 masonryCard 的 padding
|
||||
borderRadius: 16,
|
||||
@@ -848,7 +895,17 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
circumferenceCard: {
|
||||
marginBottom: 36,
|
||||
marginTop: 10,
|
||||
marginTop: 16
|
||||
},
|
||||
sectionHeader: {
|
||||
marginTop: 24,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
textAlign: 'left',
|
||||
},
|
||||
|
||||
|
||||
|
||||
496
app/_layout.tsx
@@ -1,6 +1,7 @@
|
||||
import '@/i18n';
|
||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import 'react-native-reanimated';
|
||||
@@ -8,140 +9,470 @@ 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 { notificationService } from '@/services/notifications';
|
||||
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 { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||
import { fetchMyProfile, logout, setPrivacyAgreed } from '@/store/userSlice';
|
||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { ensureHealthPermissions, initializeHealthPermissions } from '@/utils/health';
|
||||
import { DailySummaryNotificationHelpers, 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, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
|
||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
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 { 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 { isLoggedIn } = useAuthGuard()
|
||||
const fastingHydrationRequestedRef = React.useRef(false);
|
||||
const permissionInitializedRef = React.useRef(false);
|
||||
|
||||
// 初始化快捷动作处理
|
||||
useQuickActions();
|
||||
|
||||
// 注册401未授权处理器(应用启动时执行一次)
|
||||
React.useEffect(() => {
|
||||
const handle401 = async () => {
|
||||
try {
|
||||
logger.info('[401处理] 开始处理登录过期');
|
||||
|
||||
// 清除Redux状态
|
||||
await dispatch(logout());
|
||||
|
||||
// 跳转到登录页
|
||||
router.push('/auth/login');
|
||||
|
||||
logger.info('[401处理] 登录过期处理完成');
|
||||
} catch (error) {
|
||||
logger.error('[401处理] 处理失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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 loadUserData = async () => {
|
||||
// 数据已经在启动界面预加载,这里只需要快速同步到 Redux 状态
|
||||
const initializeBasicServices = async () => {
|
||||
try {
|
||||
logger.info('🚀 开始初始化基础服务(不需要权限)...');
|
||||
|
||||
if (isLoggedIn) {
|
||||
// 1. 加载用户数据(首屏展示需要)
|
||||
await dispatch(fetchMyProfile());
|
||||
logger.info('✅ 用户数据加载完成');
|
||||
}
|
||||
|
||||
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
|
||||
initializeHealthPermissions();
|
||||
logger.info('✅ HealthKit 权限系统初始化完成');
|
||||
|
||||
// 3. 初始化快捷动作(用户可能立即使用)
|
||||
await setupQuickActions();
|
||||
logger.info('✅ 快捷动作初始化完成');
|
||||
|
||||
// 5. 初始化喝水记录 Bridge
|
||||
initializeWaterRecordBridge();
|
||||
logger.info('✅ 喝水记录 Bridge 初始化完成');
|
||||
|
||||
logger.info('🎉 基础服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 基础服务初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initHealthPermissions = async () => {
|
||||
// 初始化 HealthKit 权限管理系统
|
||||
try {
|
||||
console.log('初始化 HealthKit 权限管理系统...');
|
||||
initializeHealthPermissions();
|
||||
initializeBasicServices();
|
||||
}, [dispatch]);
|
||||
|
||||
// 延迟请求权限,避免应用启动时弹窗
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await ensureHealthPermissions();
|
||||
console.log('HealthKit 权限请求完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限请求失败,可能在模拟器上运行:', error);
|
||||
// ==================== 应用状态监听 - 进入前台时清除角标 ====================
|
||||
React.useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === 'active') {
|
||||
// 应用进入前台时清除角标
|
||||
clearBadgeCount();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
console.log('HealthKit 权限管理初始化完成');
|
||||
} catch (error) {
|
||||
console.warn('HealthKit 权限管理初始化失败:', error);
|
||||
}
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ==================== 权限相关服务初始化(应用启动时执行)====================
|
||||
React.useEffect(() => {
|
||||
// 如果已经初始化过,则跳过(确保只初始化一次)
|
||||
if (permissionInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initializeNotifications = async () => {
|
||||
permissionInitializedRef.current = true;
|
||||
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
// 异步同步 Widget 数据(不阻塞主流程)
|
||||
const syncWidgetDataInBackground = async () => {
|
||||
try {
|
||||
await BackgroundTaskManager.getInstance().initialize();
|
||||
// 初始化通知服务
|
||||
await notificationService.initialize();
|
||||
console.log('通知服务初始化成功');
|
||||
|
||||
// 注册午餐提醒(12:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '');
|
||||
console.log('午餐提醒已注册');
|
||||
|
||||
// 注册晚餐提醒(18:00)
|
||||
await NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '');
|
||||
console.log('晚餐提醒已注册');
|
||||
|
||||
// 注册心情提醒(21:00)
|
||||
await MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '');
|
||||
console.log('心情提醒已注册');
|
||||
|
||||
await DailySummaryNotificationHelpers.scheduleDailySummaryNotification(profile.name || '')
|
||||
|
||||
|
||||
// 初始化快捷动作
|
||||
await setupQuickActions();
|
||||
console.log('快捷动作初始化成功');
|
||||
|
||||
// 初始化喝水记录 bridge
|
||||
initializeWaterRecordBridge();
|
||||
console.log('喝水记录 Bridge 初始化成功');
|
||||
|
||||
// 检查并同步Widget数据更改
|
||||
const widgetSync = await syncPendingWidgetChanges();
|
||||
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
||||
console.log(`检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||
logger.info(`🔄 检测到 ${widgetSync.pendingRecords.length} 条待同步的水记录`);
|
||||
|
||||
// 将待同步的记录添加到 Redux store
|
||||
// 异步处理每条记录
|
||||
for (const record of widgetSync.pendingRecords) {
|
||||
try {
|
||||
await store.dispatch(createWaterRecordAction({
|
||||
amount: record.amount,
|
||||
recordedAt: record.recordedAt,
|
||||
source: WaterRecordSource.Auto, // 标记为自动添加(来自Widget)
|
||||
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();
|
||||
initHealthPermissions();
|
||||
initializeNotifications();
|
||||
// 批量注册所有通知提醒
|
||||
const registerAllNotifications = async () => {
|
||||
try {
|
||||
logger.info('📢 开始批量注册通知提醒...');
|
||||
|
||||
// 获取用户偏好设置
|
||||
const [nutritionReminderEnabled, moodReminderEnabled, waterSettings] = await Promise.all([
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
getWaterReminderSettings(),
|
||||
]);
|
||||
|
||||
// 冷启动时清空 AI 教练会话缓存
|
||||
clearAiCoachSessionCache();
|
||||
// 准备所有通知注册任务
|
||||
const notificationTasks = [];
|
||||
|
||||
}, [dispatch]);
|
||||
// 营养提醒 - 根据用户设置决定是否注册
|
||||
if (nutritionReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 午餐提醒已注册')
|
||||
),
|
||||
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 晚餐提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启营养提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 心情提醒 - 根据用户设置决定是否注册
|
||||
if (moodReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 心情提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启心情提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 喝水提醒 - 根据用户设置决定是否注册
|
||||
if (waterSettings.enabled) {
|
||||
notificationTasks.push(
|
||||
WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', waterSettings).then(() =>
|
||||
logger.info('✅ 自定义喝水提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启喝水提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 并行执行所有通知注册任务
|
||||
if (notificationTasks.length > 0) {
|
||||
await Promise.all(notificationTasks);
|
||||
}
|
||||
|
||||
// 检查断食通知(如果有活跃计划)
|
||||
const fastingSchedule = store.getState().fasting.activeSchedule;
|
||||
if (fastingSchedule) {
|
||||
logger.info('✅ 检测到活跃的断食计划,将通过页面 hook 自动安排通知');
|
||||
}
|
||||
|
||||
logger.info('🎉 所有通知提醒注册完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 通知提醒注册失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化后台任务管理器
|
||||
const initializeBackgroundTaskManager = async () => {
|
||||
try {
|
||||
logger.info('⚙️ 初始化后台任务管理器...');
|
||||
|
||||
await BackgroundTaskManager.getInstance().initialize();
|
||||
logger.info('✅ 后台任务管理器初始化成功');
|
||||
|
||||
// 简单的任务调度检查
|
||||
const taskManager = BackgroundTaskManager.getInstance();
|
||||
const status = await taskManager.getStatus();
|
||||
|
||||
if (status === 'available') {
|
||||
const pendingRequests = await taskManager.getPendingRequests();
|
||||
if (pendingRequests.length === 0) {
|
||||
await taskManager.scheduleNextTask();
|
||||
logger.info('✅ 已调度新的后台任务');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ 后台任务管理器初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化健康监听服务(锻炼 + 睡眠)
|
||||
const initializeHealthMonitoring = async () => {
|
||||
try {
|
||||
logger.info('💪 初始化健康监听服务...');
|
||||
|
||||
const [workoutResult, sleepResult, hrvResult] = await Promise.allSettled([
|
||||
workoutMonitorService.initialize(),
|
||||
sleepMonitorService.initialize(),
|
||||
hrvMonitorService.initialize(),
|
||||
]);
|
||||
|
||||
const workoutReady = workoutResult.status === 'fulfilled';
|
||||
if (workoutReady) {
|
||||
logger.info('✅ 锻炼监听服务初始化成功');
|
||||
} else {
|
||||
logger.error('❌ 锻炼监听服务初始化失败:', workoutResult.reason);
|
||||
}
|
||||
|
||||
const sleepReady = sleepResult.status === 'fulfilled';
|
||||
if (sleepReady) {
|
||||
logger.info('✅ 睡眠监听服务初始化成功');
|
||||
} else {
|
||||
logger.error('❌ 睡眠监听服务初始化失败:', sleepResult.reason);
|
||||
}
|
||||
|
||||
const hrvReady = hrvResult.status === 'fulfilled';
|
||||
if (hrvReady) {
|
||||
logger.info('✅ HRV 监听服务初始化成功');
|
||||
} else {
|
||||
logger.error('❌ HRV 监听服务初始化失败:', hrvResult.reason);
|
||||
}
|
||||
|
||||
if (workoutReady && sleepReady && hrvReady) {
|
||||
logger.info('🎉 健康监听服务初始化完成');
|
||||
} else {
|
||||
logger.warn('⚠️ 健康监听服务部分未能初始化成功,请检查上述错误日志');
|
||||
}
|
||||
|
||||
return workoutReady && sleepReady && hrvReady;
|
||||
} catch (error) {
|
||||
logger.error('❌ 健康监听服务初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 后台任务详细状态检查(空闲时执行)
|
||||
const checkBackgroundTaskStatus = async () => {
|
||||
try {
|
||||
logger.info('🔍 检查后台任务详细状态...');
|
||||
|
||||
const taskManager = BackgroundTaskManager.getInstance();
|
||||
const status = await taskManager.getStatus();
|
||||
const statusText = await taskManager.checkStatus();
|
||||
|
||||
logger.info(`📊 后台任务状态: ${status} (${statusText})`);
|
||||
|
||||
// 检查上次执行时间
|
||||
const lastCheckTime = await taskManager.getLastBackgroundCheckTime();
|
||||
if (lastCheckTime) {
|
||||
const timeSinceLastCheck = Date.now() - lastCheckTime;
|
||||
const hoursSinceLastCheck = timeSinceLastCheck / (1000 * 60 * 60);
|
||||
logger.info(`⏱️ 上次执行: ${new Date(lastCheckTime).toLocaleString()} (${hoursSinceLastCheck.toFixed(1)}小时前)`);
|
||||
|
||||
if (hoursSinceLastCheck > 24) {
|
||||
logger.warn('⚠️ 超过24小时未执行后台任务,请检查系统设置');
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('✅ 后台任务状态检查完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 后台任务状态检查失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 权限服务初始化
|
||||
const initializePermissionServices = async () => {
|
||||
try {
|
||||
logger.info('🔐 开始初始化需要权限的服务...');
|
||||
|
||||
// 1. 初始化通知服务(包含权限请求)
|
||||
await notificationService.initialize();
|
||||
logger.info('✅ 通知服务初始化完成');
|
||||
|
||||
// 2. 清理旧的药品本地通知(迁移到服务端推送)
|
||||
cleanupLegacyMedicationNotifications().catch(error => {
|
||||
logger.error('❌ 清理旧药品通知失败:', error);
|
||||
});
|
||||
|
||||
// 3. 异步同步 Widget 数据(不阻塞主流程)
|
||||
syncWidgetDataInBackground();
|
||||
|
||||
logger.info('🎉 权限相关服务初始化完成');
|
||||
logger.info('💡 HealthKit 权限将在用户首次访问健康数据时请求');
|
||||
} catch (error) {
|
||||
logger.error('❌ 权限相关服务初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 后台服务初始化(延迟执行)====================
|
||||
const initializeBackgroundServices = () => {
|
||||
const { InteractionManager } = require('react-native');
|
||||
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
logger.info('📅 开始初始化后台服务...');
|
||||
|
||||
// 1. 批量注册所有通知提醒
|
||||
await registerAllNotifications();
|
||||
|
||||
// 2. 初始化后台任务管理器
|
||||
await initializeBackgroundTaskManager();
|
||||
|
||||
// 3. 初始化健康监听服务
|
||||
await initializeHealthMonitoring();
|
||||
|
||||
logger.info('🎉 后台服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 后台服务初始化失败:', error);
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 空闲服务初始化====================
|
||||
const initializeIdleServices = () => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
logger.info('🔄 开始初始化空闲服务...');
|
||||
|
||||
// 1. 后台任务详细状态检查
|
||||
await checkBackgroundTaskStatus();
|
||||
|
||||
// 2. 开发环境调试工具
|
||||
if (__DEV__ && BackgroundTaskDebugger) {
|
||||
logger.info('✅ 后台任务调试工具未初始化(开发环境)');
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('🎉 空闲服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('❌ 空闲服务初始化失败:', error);
|
||||
}
|
||||
}, 8000);
|
||||
};
|
||||
|
||||
const runInitializationSequence = async () => {
|
||||
try {
|
||||
await initializePermissionServices();
|
||||
} catch {
|
||||
logger.warn('⚠️ 权限相关服务初始化失败,将继续启动后台和空闲服务以便后续重试');
|
||||
}
|
||||
|
||||
// 交互完成后执行后台服务
|
||||
initializeBackgroundServices();
|
||||
|
||||
// 空闲时执行非关键服务
|
||||
initializeIdleServices();
|
||||
};
|
||||
|
||||
runInitializationSequence();
|
||||
|
||||
}, []); // 每次应用启动都执行,不依赖其他状态
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -164,12 +495,14 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<DialogProvider>
|
||||
<MembershipModalProvider>
|
||||
{children}
|
||||
<PrivacyConsentModal
|
||||
visible={showPrivacyModal}
|
||||
onAgree={handlePrivacyAgree}
|
||||
onDisagree={handlePrivacyDisagree}
|
||||
/>
|
||||
</MembershipModalProvider>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
@@ -177,6 +510,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) {
|
||||
@@ -191,21 +526,24 @@ export default function RootLayout() {
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="profile/goals" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="goals-list" options={{ headerShown: false }} />
|
||||
<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-detail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-settings" 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" />
|
||||
|
||||
@@ -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';
|
||||
@@ -17,7 +18,7 @@ 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;
|
||||
@@ -121,6 +122,13 @@ export default function LoginScreen() {
|
||||
type: 'success',
|
||||
});
|
||||
// 登录成功后处理重定向
|
||||
const shouldBack = searchParams?.shouldBack === 'true';
|
||||
|
||||
if (shouldBack) {
|
||||
// 如果设置了 shouldBack,直接返回上一页
|
||||
router.back();
|
||||
} else {
|
||||
// 否则按照原有逻辑进行重定向
|
||||
const to = searchParams?.redirectTo as string | undefined;
|
||||
const paramsJson = searchParams?.redirectParams as string | undefined;
|
||||
let parsedParams: Record<string, any> | undefined;
|
||||
@@ -132,6 +140,7 @@ export default function LoginScreen() {
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log('err.code', err.code);
|
||||
|
||||
@@ -219,9 +228,22 @@ export default function LoginScreen() {
|
||||
</View>
|
||||
{/* 自定义头部,与其它页面风格一致 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} activeOpacity={0.7}>
|
||||
<GlassView
|
||||
style={styles.backButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={[styles.backButton, styles.fallbackBackground]} activeOpacity={0.7}>
|
||||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={[styles.headerTitle, { color: color.text }]}>登录</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
@@ -229,25 +251,60 @@ export default function LoginScreen() {
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerWrap}>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>欢迎登录Out Live</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>健康生活,自律让我更自由</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Apple 登录 */}
|
||||
{appleAvailable && (
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
onPress={() => guardAgreement(onAppleLogin)}
|
||||
disabled={loading}
|
||||
style={({ pressed }) => [
|
||||
styles.appleButton,
|
||||
{ backgroundColor: '#000000' },
|
||||
loading && { opacity: 0.7 },
|
||||
pressed && { transform: [{ scale: 0.98 }] },
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.appleButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(0, 0, 0, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text style={styles.appleText}>登录中...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.appleButton, styles.appleButtonFallback, loading && { opacity: 0.7 }]}>
|
||||
{loading ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text style={styles.appleText}>登录中...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 协议勾选 */}
|
||||
@@ -296,7 +353,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,
|
||||
@@ -319,12 +386,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,
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
@@ -25,6 +26,7 @@ type BasalMetabolismData = {
|
||||
export default function BasalMetabolismDetailScreen() {
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const userAge = useAppSelector(selectUserAge);
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
// 日期相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -329,11 +331,14 @@ export default function BasalMetabolismDetailScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 20
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { ChallengeSource } from '@/services/challengesApi';
|
||||
import {
|
||||
fetchChallengeDetail,
|
||||
fetchChallengeRankings,
|
||||
@@ -23,12 +24,17 @@ import {
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -43,6 +49,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { captureRef } from 'react-native-view-shot';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const HERO_HEIGHT = width * 0.76;
|
||||
@@ -85,9 +92,13 @@ export default function ChallengeDetailScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
// 用于截图分享的引用
|
||||
const shareCardRef = useRef<View>(null);
|
||||
|
||||
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
||||
|
||||
@@ -150,6 +161,24 @@ export default function ChallengeDetailScreen() {
|
||||
}, [showCelebration]);
|
||||
|
||||
const progress = challenge?.progress;
|
||||
const isJoined = challenge?.isJoined ?? false;
|
||||
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
|
||||
const lastProgressAt = useMemo(() => {
|
||||
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
|
||||
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
|
||||
}, [challenge?.progress]);
|
||||
const hasCheckedInToday = useMemo(() => {
|
||||
if (!challenge?.progress) {
|
||||
return false;
|
||||
}
|
||||
if (lastProgressAt) {
|
||||
const lastDate = dayjs(lastProgressAt);
|
||||
if (lastDate.isValid() && lastDate.isSame(dayjs(), 'day')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return challenge.progress.checkedInToday ?? false;
|
||||
}, [challenge?.progress, lastProgressAt]);
|
||||
|
||||
const rankingData = useMemo(() => {
|
||||
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
||||
@@ -160,6 +189,7 @@ export default function ChallengeDetailScreen() {
|
||||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||
[rankingData],
|
||||
);
|
||||
const showShareCode = isJoined && Boolean(challenge?.shareCode);
|
||||
|
||||
const handleViewAllRanking = () => {
|
||||
if (!id) {
|
||||
@@ -180,17 +210,35 @@ export default function ChallengeDetailScreen() {
|
||||
);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!challenge) {
|
||||
if (!challenge || !shareCardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Toast.show({
|
||||
type: 'info',
|
||||
text1: t('challengeDetail.share.generating'),
|
||||
});
|
||||
|
||||
// 捕获分享卡片视图
|
||||
const uri = await captureRef(shareCardRef, {
|
||||
format: 'png',
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
// 分享图片
|
||||
const shareMessage = isJoined && progress
|
||||
? t('challengeDetail.share.messageJoined', { title: challenge.title, completed: progress.completed, target: progress.target })
|
||||
: t('challengeDetail.share.messageNotJoined', { title: challenge.title });
|
||||
|
||||
await Share.share({
|
||||
title: challenge.title,
|
||||
message: `我正在参与「${challenge.title}」,一起坚持吧!`,
|
||||
url: challenge.image,
|
||||
message: shareMessage,
|
||||
url: Platform.OS === 'ios' ? uri : `file://${uri}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('分享失败', error);
|
||||
Toast.error(t('challengeDetail.share.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -206,10 +254,12 @@ export default function ChallengeDetailScreen() {
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(joinChallenge(id));
|
||||
await dispatch(joinChallenge(id)).unwrap();
|
||||
await dispatch(fetchChallengeDetail(id)).unwrap();
|
||||
await dispatch(fetchChallengeRankings({ id }));
|
||||
setShowCelebration(true)
|
||||
} catch (error) {
|
||||
Toast.error('加入挑战失败')
|
||||
Toast.error(t('challengeDetail.alert.joinFailed'))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,7 +271,7 @@ export default function ChallengeDetailScreen() {
|
||||
await dispatch(leaveChallenge(id)).unwrap();
|
||||
await dispatch(fetchChallengeDetail(id)).unwrap();
|
||||
} catch (error) {
|
||||
Toast.error('退出挑战失败');
|
||||
Toast.error(t('challengeDetail.alert.leaveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,34 +279,76 @@ export default function ChallengeDetailScreen() {
|
||||
if (!id || leaveStatus === 'loading') {
|
||||
return;
|
||||
}
|
||||
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
Alert.alert(
|
||||
t('challengeDetail.alert.leaveConfirm.title'),
|
||||
t('challengeDetail.alert.leaveConfirm.message'),
|
||||
[
|
||||
{ text: t('challengeDetail.alert.leaveConfirm.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: '退出挑战',
|
||||
text: t('challengeDetail.alert.leaveConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void handleLeave();
|
||||
},
|
||||
},
|
||||
]);
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleProgressReport = () => {
|
||||
const handleProgressReport = async () => {
|
||||
if (!id || progressStatus === 'loading') {
|
||||
return;
|
||||
}
|
||||
dispatch(reportChallengeProgress({ id }));
|
||||
|
||||
if (hasCheckedInToday) {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.alreadyChecked'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge?.status === 'upcoming') {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.notStarted'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge?.status === 'expired') {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.expired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isJoined) {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.mustJoin'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(reportChallengeProgress({ id, value: 1 })).unwrap();
|
||||
Toast.success(t('challengeDetail.checkIn.toast.success'));
|
||||
} catch (error) {
|
||||
Toast.error(t('challengeDetail.checkIn.toast.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyShareCode = async () => {
|
||||
if (!challenge?.shareCode) return;
|
||||
await Clipboard.setStringAsync(challenge.shareCode);
|
||||
// 添加震动反馈
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Toast.success(t('challengeDetail.shareCode.copied'));
|
||||
};
|
||||
|
||||
const isJoined = challenge?.isJoined ?? false;
|
||||
const isLoadingInitial = detailStatus === 'loading' && !challenge;
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战,稍后再试试吧。</Text>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.notFound')}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -265,10 +357,10 @@ export default function ChallengeDetailScreen() {
|
||||
if (isLoadingInitial) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<View style={styles.missingContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>加载挑战详情中…</Text>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>{t('challengeDetail.loading')}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -277,43 +369,43 @@ export default function ChallengeDetailScreen() {
|
||||
if (!challenge) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||
{detailError ?? '未找到该挑战,稍后再试试吧。'}
|
||||
{detailError ?? t('challengeDetail.notFound')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => dispatch(fetchChallengeDetail(id))}
|
||||
>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challengeDetail.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
||||
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
||||
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
|
||||
const highlightTitle = challenge.highlightTitle ?? t('challengeDetail.highlight.join.title');
|
||||
const highlightSubtitle = challenge.highlightSubtitle ?? t('challengeDetail.highlight.join.subtitle');
|
||||
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
|
||||
const isUpcoming = challenge.status === 'upcoming';
|
||||
const isExpired = challenge.status === 'expired';
|
||||
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
||||
const upcomingHighlightTitle = '挑战即将开始';
|
||||
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
|
||||
const upcomingHighlightSubtitle = upcomingStartLabel
|
||||
? `${upcomingStartLabel} 开始,敬请期待`
|
||||
: '挑战即将开启,敬请期待';
|
||||
const upcomingCtaLabel = '挑战即将开始';
|
||||
? t('challengeDetail.highlight.upcoming.subtitle', { date: upcomingStartLabel })
|
||||
: t('challengeDetail.highlight.upcoming.subtitleFallback');
|
||||
const upcomingCtaLabel = t('challengeDetail.cta.upcoming');
|
||||
const expiredEndLabel = formatMonthDay(challenge.endAt);
|
||||
const expiredHighlightTitle = '挑战已结束';
|
||||
const expiredHighlightTitle = t('challengeDetail.highlight.expired.title');
|
||||
const expiredHighlightSubtitle = expiredEndLabel
|
||||
? `${expiredEndLabel} 已截止,期待下一次挑战`
|
||||
: '本轮挑战已结束,期待下一次挑战';
|
||||
const expiredCtaLabel = '挑战已结束';
|
||||
const leaveHighlightTitle = '先别急着离开';
|
||||
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
|
||||
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
|
||||
? t('challengeDetail.highlight.expired.subtitle', { date: expiredEndLabel })
|
||||
: t('challengeDetail.highlight.expired.subtitleFallback');
|
||||
const expiredCtaLabel = t('challengeDetail.cta.expired');
|
||||
const leaveHighlightTitle = t('challengeDetail.highlight.leave.title');
|
||||
const leaveHighlightSubtitle = t('challengeDetail.highlight.leave.subtitle');
|
||||
const leaveCtaLabel = leaveStatus === 'loading' ? t('challengeDetail.cta.leaving') : t('challengeDetail.cta.leave');
|
||||
|
||||
let floatingHighlightTitle = highlightTitle;
|
||||
let floatingHighlightSubtitle = highlightSubtitle;
|
||||
@@ -324,8 +416,10 @@ export default function ChallengeDetailScreen() {
|
||||
let isDisabledButtonState = false;
|
||||
|
||||
if (isJoined) {
|
||||
floatingHighlightTitle = leaveHighlightTitle;
|
||||
floatingHighlightSubtitle = leaveHighlightSubtitle;
|
||||
floatingHighlightTitle = showShareCode
|
||||
? `分享码 ${challenge?.shareCode ?? ''}`
|
||||
: leaveHighlightTitle;
|
||||
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
|
||||
floatingCtaLabel = leaveCtaLabel;
|
||||
floatingOnPress = handleLeaveConfirm;
|
||||
floatingDisabled = leaveStatus === 'loading';
|
||||
@@ -355,22 +449,154 @@ export default function ChallengeDetailScreen() {
|
||||
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||||
|
||||
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
||||
const checkInDisabled =
|
||||
progressStatus === 'loading' || hasCheckedInToday || !isJoined || isUpcoming || isExpired;
|
||||
const checkInButtonLabel =
|
||||
progressStatus === 'loading'
|
||||
? t('challengeDetail.checkIn.button.checking')
|
||||
: hasCheckedInToday
|
||||
? t('challengeDetail.checkIn.button.checked')
|
||||
: !isJoined
|
||||
? t('challengeDetail.checkIn.button.notJoined')
|
||||
: isUpcoming
|
||||
? t('challengeDetail.checkIn.button.upcoming')
|
||||
: isExpired
|
||||
? t('challengeDetail.checkIn.button.expired')
|
||||
: t('challengeDetail.checkIn.button.checkIn');
|
||||
const checkInSubtitle = hasCheckedInToday
|
||||
? t('challengeDetail.checkIn.subtitleChecked')
|
||||
: t('challengeDetail.checkIn.subtitle');
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
{/* 隐藏的分享卡片,用于截图 */}
|
||||
<View style={styles.offscreenContainer}>
|
||||
<View ref={shareCardRef} style={styles.shareCard} collapsable={false}>
|
||||
{/* 背景图片 */}
|
||||
<Image
|
||||
source={{ uri: challenge.image }}
|
||||
style={styles.shareCardBg}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.4)', 'rgba(0,0,0,0.65)']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
{/* 分享卡片内容 */}
|
||||
<View style={styles.shareCardContent}>
|
||||
<Text style={styles.shareCardTitle}>{challenge.title}</Text>
|
||||
{challenge.summary ? (
|
||||
<Text style={styles.shareCardSummary} numberOfLines={2}>
|
||||
{challenge.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* 根据是否加入显示不同内容 */}
|
||||
{isJoined && progress ? (
|
||||
// 已加入:显示个人进度
|
||||
<View style={styles.shareProgressContainer}>
|
||||
<View style={styles.shareProgressHeader}>
|
||||
<Text style={styles.shareProgressLabel}>{t('challengeDetail.shareCard.progress.label')}</Text>
|
||||
<Text style={styles.shareProgressValue}>
|
||||
{t('challengeDetail.shareCard.progress.days', { completed: progress.completed, target: progress.target })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.shareProgressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.shareProgressBar,
|
||||
{ width: `${Math.min(100, (progress.completed / progress.target) * 100)}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.shareProgressSubtext}>
|
||||
{progress.completed === progress.target
|
||||
? t('challengeDetail.shareCard.progress.completed')
|
||||
: t('challengeDetail.shareCard.progress.remaining', { remaining: progress.target - progress.completed })}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
// 未加入:显示挑战信息
|
||||
<View style={styles.shareInfoContainer}>
|
||||
<View style={styles.shareInfoRow}>
|
||||
<View style={styles.shareInfoIconWrapper}>
|
||||
<Ionicons name="calendar-outline" size={20} color="#5E8BFF" />
|
||||
</View>
|
||||
<View style={styles.shareInfoTextWrapper}>
|
||||
<Text style={styles.shareInfoLabel}>{dateRangeLabel}</Text>
|
||||
{challenge.durationLabel ? (
|
||||
<Text style={styles.shareInfoMeta}>{challenge.durationLabel}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.shareInfoRow}>
|
||||
<View style={styles.shareInfoIconWrapper}>
|
||||
<Ionicons name="flag-outline" size={20} color="#5E8BFF" />
|
||||
</View>
|
||||
<View style={styles.shareInfoTextWrapper}>
|
||||
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
|
||||
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.shareInfoRow}>
|
||||
<View style={styles.shareInfoIconWrapper}>
|
||||
<Ionicons name="people-outline" size={20} color="#5E8BFF" />
|
||||
</View>
|
||||
<View style={styles.shareInfoTextWrapper}>
|
||||
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
|
||||
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.joinUs')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 底部标识 */}
|
||||
<View style={styles.shareCardFooter}>
|
||||
<Text style={styles.shareCardFooterText}>{t('challengeDetail.shareCard.footer')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.container}>
|
||||
<View pointerEvents="box-none" style={[styles.headerOverlay, { paddingTop: insets.top }]}>
|
||||
<HeaderBar
|
||||
title=""
|
||||
backColor="white"
|
||||
tone="light"
|
||||
transparent
|
||||
withSafeTop={false}
|
||||
// right={
|
||||
// <TouchableOpacity style={styles.circularButton} activeOpacity={0.85} onPress={handleShare}>
|
||||
// <Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||
// </TouchableOpacity>
|
||||
// }
|
||||
right={
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.shareButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={handleShare}
|
||||
style={[styles.shareButton, styles.fallbackShareButton]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={20} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -428,7 +654,7 @@ export default function ChallengeDetailScreen() {
|
||||
</View>
|
||||
<View style={styles.detailTextWrapper}>
|
||||
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
|
||||
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
||||
<Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -450,19 +676,50 @@ export default function ChallengeDetailScreen() {
|
||||
))}
|
||||
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||||
<TouchableOpacity style={styles.moreAvatarButton}>
|
||||
<Text style={styles.moreAvatarText}>更多</Text>
|
||||
<Text style={styles.moreAvatarText}>{t('challengeDetail.participants.more')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCustomChallenge ? (
|
||||
<View style={styles.checkInCard}>
|
||||
<View style={styles.checkInCopy}>
|
||||
<Text style={styles.checkInTitle}>{hasCheckedInToday ? t('challengeDetail.checkIn.todayChecked') : t('challengeDetail.checkIn.title')}</Text>
|
||||
<Text style={styles.checkInSubtitle}>{checkInSubtitle}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleProgressReport}
|
||||
disabled={checkInDisabled}
|
||||
style={styles.checkInButton}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={checkInDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.checkInButtonBackground}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.checkInButtonLabel,
|
||||
checkInDisabled && styles.checkInButtonLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{checkInButtonLabel}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>排行榜</Text>
|
||||
<Text style={styles.sectionTitle}>{t('challengeDetail.ranking.title')}</Text>
|
||||
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||||
<Text style={styles.sectionAction}>查看全部</Text>
|
||||
<Text style={styles.sectionAction}>{t('challengeDetail.detail.viewAllRanking')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -483,7 +740,7 @@ export default function ChallengeDetailScreen() {
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyRanking}>
|
||||
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||
<Text style={styles.emptyRankingText}>{t('challengeDetail.ranking.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -492,11 +749,30 @@ export default function ChallengeDetailScreen() {
|
||||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||
<View style={styles.floatingCTAContent}>
|
||||
{showShareCode ? (
|
||||
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
|
||||
<View style={styles.shareCodeRow}>
|
||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.shareCodeIconButton}
|
||||
onPress={handleCopyShareCode}
|
||||
>
|
||||
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{floatingHighlightSubtitle ? (
|
||||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||
) : null}
|
||||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.highlightCopy}>
|
||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.highlightButton}
|
||||
activeOpacity={0.9}
|
||||
@@ -592,6 +868,19 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
highlightCopyCompact: {
|
||||
marginRight: 12,
|
||||
gap: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
shareCodeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
},
|
||||
headerTextBlock: {
|
||||
paddingHorizontal: 24,
|
||||
marginTop: HERO_HEIGHT - 60,
|
||||
@@ -694,6 +983,49 @@ const styles = StyleSheet.create({
|
||||
color: '#4F5BD5',
|
||||
fontWeight: '600',
|
||||
},
|
||||
checkInCard: {
|
||||
marginTop: 4,
|
||||
padding: 14,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#f5f6ff',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
checkInCopy: {
|
||||
flex: 1,
|
||||
},
|
||||
checkInTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
checkInSubtitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6f7ba7',
|
||||
lineHeight: 18,
|
||||
},
|
||||
checkInButton: {
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
checkInButtonBackground: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 18,
|
||||
minWidth: 96,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkInButtonLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
},
|
||||
checkInButtonLabelDisabled: {
|
||||
color: '#6f7799',
|
||||
},
|
||||
sectionHeader: {
|
||||
marginTop: 36,
|
||||
marginHorizontal: 24,
|
||||
@@ -749,6 +1081,10 @@ const styles = StyleSheet.create({
|
||||
color: '#5f6a97',
|
||||
lineHeight: 18,
|
||||
},
|
||||
shareCodeIconButton: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
ctaErrorText: {
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
@@ -773,21 +1109,19 @@ const styles = StyleSheet.create({
|
||||
highlightButtonLabelDisabled: {
|
||||
color: '#6f7799',
|
||||
},
|
||||
circularButton: {
|
||||
shareButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.24)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackShareButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.24)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
shareIcon: {
|
||||
fontSize: 18,
|
||||
color: '#ffffff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
missingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
@@ -819,4 +1153,130 @@ const styles = StyleSheet.create({
|
||||
width: width * 1.3,
|
||||
height: width * 1.3,
|
||||
},
|
||||
// 分享卡片样式
|
||||
offscreenContainer: {
|
||||
position: 'absolute',
|
||||
left: -9999,
|
||||
top: -9999,
|
||||
opacity: 0,
|
||||
},
|
||||
shareCard: {
|
||||
width: 375,
|
||||
height: 500,
|
||||
backgroundColor: '#fff',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 24,
|
||||
},
|
||||
shareCardBg: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
shareCardContent: {
|
||||
flex: 1,
|
||||
padding: 24,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
shareCardTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
marginTop: 20,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
shareCardSummary: {
|
||||
fontSize: 15,
|
||||
color: '#ffffff',
|
||||
marginTop: 12,
|
||||
lineHeight: 22,
|
||||
opacity: 0.95,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.25)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
shareProgressContainer: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginTop: 'auto',
|
||||
},
|
||||
shareInfoContainer: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginTop: 'auto',
|
||||
gap: 16,
|
||||
},
|
||||
shareInfoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
shareInfoIconWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#EEF0FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shareInfoTextWrapper: {
|
||||
marginLeft: 12,
|
||||
flex: 1,
|
||||
},
|
||||
shareInfoLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
shareInfoMeta: {
|
||||
fontSize: 12,
|
||||
color: '#707baf',
|
||||
marginTop: 2,
|
||||
},
|
||||
shareProgressHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
shareProgressLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
shareProgressValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#5E8BFF',
|
||||
},
|
||||
shareProgressTrack: {
|
||||
height: 8,
|
||||
backgroundColor: '#eceffa',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shareProgressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: '#5E8BFF',
|
||||
borderRadius: 4,
|
||||
},
|
||||
shareProgressSubtext: {
|
||||
fontSize: 13,
|
||||
color: '#707baf',
|
||||
marginTop: 12,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
shareCardFooter: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 16,
|
||||
},
|
||||
shareCardFooterText: {
|
||||
fontSize: 12,
|
||||
color: '#ffffff',
|
||||
opacity: 0.8,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
fetchChallengeDetail,
|
||||
fetchChallengeRankings,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
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();
|
||||
@@ -74,6 +76,9 @@ export default function ChallengeLeaderboardScreen() {
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战。</Text>
|
||||
</View>
|
||||
@@ -144,7 +149,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40 }}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
|
||||
976
app/challenges/create-custom.tsx
Normal file
@@ -0,0 +1,976 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
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, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi';
|
||||
import {
|
||||
createCustomChallengeThunk,
|
||||
fetchChallenges,
|
||||
selectCreateChallengeError,
|
||||
selectCreateChallengeStatus,
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
|
||||
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [
|
||||
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' },
|
||||
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' },
|
||||
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' },
|
||||
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' },
|
||||
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' },
|
||||
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' },
|
||||
];
|
||||
|
||||
const FALLBACK_IMAGE =
|
||||
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1200&q=80';
|
||||
|
||||
type PickerType = 'start' | 'end' | null;
|
||||
|
||||
export default function CreateCustomChallengeScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const createStatus = useAppSelector(selectCreateChallengeStatus);
|
||||
const createError = useAppSelector(selectCreateChallengeError);
|
||||
const isCreating = createStatus === 'loading';
|
||||
|
||||
const today = useMemo(() => dayjs().startOf('day').toDate(), []);
|
||||
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [image, setImage] = useState<string | undefined>(FALLBACK_IMAGE);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const { upload, uploading } = useCosUpload({ prefix: 'images/challenges' });
|
||||
const [type, setType] = useState<ChallengeType>(ChallengeType.WATER);
|
||||
const [startDate, setStartDate] = useState<Date>(today);
|
||||
const [endDate, setEndDate] = useState<Date>(defaultEnd);
|
||||
const [targetValue, setTargetValue] = useState('');
|
||||
const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
|
||||
const [requirementLabel, setRequirementLabel] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [progressUnit] = useState('天');
|
||||
const [periodLabel, setPeriodLabel] = useState('');
|
||||
const [periodEdited, setPeriodEdited] = useState(false);
|
||||
const [rankingDescription] = useState('连续打卡榜');
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [maxParticipants, setMaxParticipants] = useState('100');
|
||||
const [minimumEdited, setMinimumEdited] = useState(false);
|
||||
|
||||
const [shareCode, setShareCode] = useState<string | null>(null);
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false);
|
||||
const [createdChallengeId, setCreatedChallengeId] = useState<string | null>(null);
|
||||
|
||||
const [pickerType, setPickerType] = useState<PickerType>(null);
|
||||
|
||||
const durationDays = useMemo(
|
||||
() =>
|
||||
Math.max(
|
||||
1,
|
||||
dayjs(endDate).startOf('day').diff(dayjs(startDate).startOf('day'), 'day') + 1
|
||||
),
|
||||
[startDate, endDate]
|
||||
);
|
||||
const durationLabel = useMemo(() => `持续${durationDays}天`, [durationDays]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!periodEdited) {
|
||||
setPeriodLabel(`${durationDays}天挑战`);
|
||||
}
|
||||
if (!minimumEdited) {
|
||||
setMinimumCheckInDays(String(durationDays));
|
||||
}
|
||||
}, [durationDays, minimumEdited, periodEdited]);
|
||||
|
||||
const handleConfirmDate = (date: Date) => {
|
||||
if (!pickerType) return;
|
||||
const normalized = dayjs(date).startOf('day');
|
||||
if (pickerType === 'start') {
|
||||
const nextStart = normalized.isAfter(dayjs(), 'day')
|
||||
? normalized
|
||||
: dayjs().add(1, 'day').startOf('day');
|
||||
setStartDate(nextStart.toDate());
|
||||
if (dayjs(endDate).isSameOrBefore(nextStart)) {
|
||||
const nextEnd = nextStart.add(20, 'day').toDate();
|
||||
setEndDate(nextEnd);
|
||||
}
|
||||
} else {
|
||||
const minEnd = dayjs(startDate).add(1, 'day').startOf('day');
|
||||
const nextEnd = normalized.isAfter(minEnd) ? normalized : minEnd;
|
||||
setEndDate(nextEnd.toDate());
|
||||
}
|
||||
setPickerType(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isCreating) return;
|
||||
if (!title.trim()) {
|
||||
Toast.warning('请填写挑战标题');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requirementLabel.trim()) {
|
||||
Toast.warning('请填写挑战要求说明');
|
||||
return;
|
||||
}
|
||||
|
||||
const startTimestamp = dayjs(startDate).valueOf();
|
||||
const endTimestamp = dayjs(endDate).valueOf();
|
||||
if (endTimestamp <= startTimestamp) {
|
||||
Toast.warning('结束时间需要晚于开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = Number(targetValue);
|
||||
if (!Number.isFinite(target) || target < 1 || target > 1000) {
|
||||
Toast.warning('每日目标值需在 1-1000 之间');
|
||||
return;
|
||||
}
|
||||
|
||||
const minDays = Number(minimumCheckInDays) || durationDays;
|
||||
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
|
||||
Toast.warning('最少打卡天数需在 1-365 之间');
|
||||
return;
|
||||
}
|
||||
if (minDays > durationDays) {
|
||||
Toast.warning('最少打卡天数不能超过持续天数');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxP = maxParticipants ? Number(maxParticipants) : null;
|
||||
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
|
||||
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制');
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTitle = title.trim() || '自定义挑战';
|
||||
const payload: CreateCustomChallengePayload = {
|
||||
title: safeTitle,
|
||||
type,
|
||||
image: image?.trim() || undefined,
|
||||
startAt: startTimestamp,
|
||||
endAt: endTimestamp,
|
||||
targetValue: target,
|
||||
minimumCheckInDays: minDays,
|
||||
durationLabel,
|
||||
requirementLabel: requirementLabel.trim() || '请填写挑战要求',
|
||||
summary: summary.trim() || undefined,
|
||||
progressUnit: progressUnit.trim() || '天',
|
||||
periodLabel: periodLabel.trim() || undefined,
|
||||
rankingDescription: rankingDescription.trim() || undefined,
|
||||
isPublic,
|
||||
maxParticipants: maxP,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
|
||||
setShareCode(created.shareCode ?? null);
|
||||
setCreatedChallengeId(created.id);
|
||||
setShareModalVisible(true);
|
||||
Toast.success('自定义挑战已创建');
|
||||
dispatch(fetchChallenges());
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : '创建失败,请稍后再试';
|
||||
Toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyShareCode = async () => {
|
||||
if (!shareCode) return;
|
||||
await Clipboard.setStringAsync(shareCode);
|
||||
Toast.success('邀请码已复制');
|
||||
};
|
||||
|
||||
const handleTargetInputChange = (value: string) => {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setTargetValue('');
|
||||
return;
|
||||
}
|
||||
const num = Math.min(1000, parseInt(digits, 10));
|
||||
setTargetValue(String(num));
|
||||
};
|
||||
|
||||
const handleMinimumDaysChange = (value: string) => {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setMinimumCheckInDays('');
|
||||
setMinimumEdited(true);
|
||||
return;
|
||||
}
|
||||
const num = Math.max(1, Math.min(365, parseInt(digits, 10)));
|
||||
if (num > durationDays) {
|
||||
setMinimumCheckInDays(String(durationDays));
|
||||
setMinimumEdited(true);
|
||||
return;
|
||||
}
|
||||
setMinimumEdited(true);
|
||||
setMinimumCheckInDays(String(num));
|
||||
};
|
||||
|
||||
const handlePickImage = useCallback(() => {
|
||||
Alert.alert(
|
||||
'选择封面图',
|
||||
'请选择封面来源',
|
||||
[
|
||||
{
|
||||
text: '拍照',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相机权限以拍摄封面');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.6,
|
||||
aspect: [16, 9],
|
||||
});
|
||||
if (result.canceled || !result.assets?.length) return;
|
||||
const asset = result.assets[0];
|
||||
setImagePreview(asset.uri);
|
||||
setImage(undefined);
|
||||
try {
|
||||
const { url } = await upload(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
|
||||
type: asset.mimeType ?? 'image/jpeg',
|
||||
},
|
||||
{ prefix: 'images/challenges' }
|
||||
);
|
||||
setImage(url);
|
||||
setImagePreview(null);
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 封面上传失败', error);
|
||||
Alert.alert('上传失败', '封面上传失败,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 拍照失败', error);
|
||||
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '从相册选择',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相册权限以选择封面');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.9,
|
||||
});
|
||||
if (result.canceled || !result.assets?.length) return;
|
||||
const asset = result.assets[0];
|
||||
setImagePreview(asset.uri);
|
||||
setImage(undefined);
|
||||
try {
|
||||
const { url } = await upload(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
|
||||
type: asset.mimeType ?? 'image/jpeg',
|
||||
},
|
||||
{ prefix: 'images/challenges' }
|
||||
);
|
||||
setImage(url);
|
||||
setImagePreview(null);
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 封面上传失败', error);
|
||||
Alert.alert('上传失败', '封面上传失败,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 选择封面失败', error);
|
||||
Alert.alert('选择失败', '无法打开相册,请稍后再试');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ text: '取消', style: 'cancel' },
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
}, [upload]);
|
||||
|
||||
const handleViewChallenge = () => {
|
||||
setShareModalVisible(false);
|
||||
if (createdChallengeId) {
|
||||
router.replace({ pathname: '/challenges/[id]', params: { id: createdChallengeId } });
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (val: string) => void,
|
||||
placeholder?: string,
|
||||
keyboardType: 'default' | 'numeric' = 'default',
|
||||
onFocus?: () => void
|
||||
) => (
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
keyboardType={keyboardType}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderTextarea = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (val: string) => void,
|
||||
placeholder?: string
|
||||
) => (
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.input, styles.textarea]}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`;
|
||||
const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<HeaderBar title="新建挑战" transparent />
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={80}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 140) + insets.bottom },
|
||||
]}
|
||||
>
|
||||
<View style={styles.heroContainer}>
|
||||
<Image
|
||||
source={{ uri: heroImageSource }}
|
||||
style={styles.heroImage}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<View style={styles.heroOverlay}>
|
||||
<Text style={styles.heroKicker}>自定义挑战</Text>
|
||||
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text>
|
||||
<Text style={styles.heroMeta}>{progressMeta}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<View style={styles.formHeader}>
|
||||
<Text style={styles.sectionTitle}>基础信息</Text>
|
||||
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null}
|
||||
</View>
|
||||
{renderField('标题', title, setTitle, '挑战标题(最多100字)')}
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>封面图</Text>
|
||||
<View style={styles.uploadRow}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
|
||||
onPress={handlePickImage}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text>
|
||||
</TouchableOpacity>
|
||||
{image || imagePreview ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
setImagePreview(null);
|
||||
setImage(undefined);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.clearUpload}>清除</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.helperText}>建议比例 16:9,清晰展示挑战氛围</Text>
|
||||
</View>
|
||||
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')}
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>挑战设置</Text>
|
||||
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>挑战类型</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{typeOptions.map((option) => {
|
||||
const active = option.value === type;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => setType(option.value)}
|
||||
style={[
|
||||
styles.chip,
|
||||
active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.chipLabel,
|
||||
active && { color: option.accent, fontWeight: '700' },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>时间范围</Text>
|
||||
<View style={styles.dateRow}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.datePill}
|
||||
onPress={() => setPickerType('start')}
|
||||
>
|
||||
<Text style={styles.dateLabel}>开始</Text>
|
||||
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.datePill}
|
||||
onPress={() => setPickerType('end')}
|
||||
>
|
||||
<Text style={styles.dateLabel}>结束</Text>
|
||||
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inlineFields}>
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>持续时间</Text>
|
||||
<View style={styles.readonlyPill}>
|
||||
<Text style={styles.readonlyText}>{durationLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{renderField('周期标签', periodLabel, (v) => {
|
||||
setPeriodEdited(true);
|
||||
setPeriodLabel(v);
|
||||
}, '如:21天挑战')}
|
||||
</View>
|
||||
|
||||
<View style={styles.inlineFields}>
|
||||
{renderField('每日目标值', targetValue, handleTargetInputChange, '如:8', 'numeric')}
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>进度单位</Text>
|
||||
<View style={styles.readonlyPill}>
|
||||
<Text style={styles.readonlyText}>{progressUnit}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')}
|
||||
|
||||
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>展示&互动</Text>
|
||||
<View style={styles.inlineFields}>
|
||||
{renderField('参与人数上限', maxParticipants, (v) => {
|
||||
const digits = v.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setMaxParticipants('');
|
||||
return;
|
||||
}
|
||||
setMaxParticipants(String(parseInt(digits, 10)));
|
||||
}, '留空表示无限制', 'numeric')}
|
||||
</View>
|
||||
<View style={styles.switchRow}>
|
||||
<View>
|
||||
<Text style={styles.fieldLabel}>是否公开</Text>
|
||||
<Text style={styles.switchHint}>公开后其他用户可通过邀请码加入</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={setIsPublic}
|
||||
trackColor={{ true: colorTokens.primary, false: '#cbd5e1' }}
|
||||
thumbColor={isPublic ? '#ffffff' : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<View pointerEvents="box-none" style={[styles.floatingCTA, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<BlurView intensity={14} tint="light" style={styles.floatingBlur}>
|
||||
<View style={styles.floatingContent}>
|
||||
<View style={styles.floatingCopy}>
|
||||
<Text style={styles.floatingTitle}>生成自定义挑战</Text>
|
||||
<Text style={styles.floatingSubtitle}>自动创建分享码,邀请好友一起挑战</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.floatingButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#5E8BFF', '#6B6CFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.floatingButtonBackground}
|
||||
>
|
||||
<Text style={styles.floatingButtonLabel}>
|
||||
{isCreating ? '创建中…' : '创建并生成邀请码'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
|
||||
<DateTimePickerModal
|
||||
isVisible={pickerType !== null}
|
||||
mode="date"
|
||||
date={pickerType === 'end' ? endDate : startDate}
|
||||
minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()}
|
||||
onConfirm={handleConfirmDate}
|
||||
onCancel={() => setPickerType(null)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={shareModalVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShareModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.shareCard}>
|
||||
<Text style={styles.shareTitle}>邀请码已生成</Text>
|
||||
<Text style={styles.shareSubtitle}>分享给好友即可加入挑战</Text>
|
||||
<View style={styles.shareCodeBadge}>
|
||||
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text>
|
||||
</View>
|
||||
<View style={styles.shareActions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.shareButtonGhost}
|
||||
onPress={handleCopyShareCode}
|
||||
disabled={!shareCode}
|
||||
>
|
||||
<Text style={styles.shareButtonGhostLabel}>复制邀请码</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.shareButtonPrimary}
|
||||
onPress={handleViewChallenge}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#5E8BFF', '#6B6CFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.shareButtonPrimary}
|
||||
>
|
||||
<Text style={styles.shareButtonPrimaryLabel}>查看挑战</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.shareClose}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => setShareModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.shareCloseLabel}>稍后再说</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 160,
|
||||
},
|
||||
heroContainer: {
|
||||
height: 260,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 22,
|
||||
left: 20,
|
||||
right: 20,
|
||||
},
|
||||
heroKicker: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: '700',
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 8,
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
textShadowColor: 'rgba(0,0,0,0.25)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
heroMeta: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
color: '#e2e8f0',
|
||||
fontWeight: '600',
|
||||
},
|
||||
formCard: {
|
||||
marginTop: 14,
|
||||
marginHorizontal: 20,
|
||||
padding: 18,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.12)',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 8,
|
||||
gap: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
fieldBlock: {
|
||||
gap: 6,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
input: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
},
|
||||
textarea: {
|
||||
minHeight: 90,
|
||||
},
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f8fafc',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: 13,
|
||||
color: '#334155',
|
||||
},
|
||||
uploadRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
uploadButton: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#EEF1FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
uploadButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
uploadButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#4F5BD5',
|
||||
},
|
||||
clearUpload: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#9ca3af',
|
||||
},
|
||||
helperText: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
dateRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
datePill: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
dateValue: {
|
||||
marginTop: 4,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
readonlyPill: {
|
||||
marginTop: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
readonlyText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
inlineFields: {
|
||||
gap: 12,
|
||||
},
|
||||
switchRow: {
|
||||
marginTop: 6,
|
||||
padding: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
switchHint: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
formHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
inlineError: {
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
floatingCTA: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 10,
|
||||
},
|
||||
floatingBlur: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
backgroundColor: 'rgba(243, 244, 251, 0.9)',
|
||||
},
|
||||
floatingContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
floatingCopy: {
|
||||
flex: 1,
|
||||
},
|
||||
floatingTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
floatingSubtitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
floatingButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
floatingButtonBackground: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
floatingButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
shareCard: {
|
||||
width: '100%',
|
||||
padding: 20,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 20,
|
||||
elevation: 12,
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
shareTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
shareSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
},
|
||||
shareCodeBadge: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#EEF1FF',
|
||||
},
|
||||
shareCode: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: '#4F5BD5',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
shareActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
},
|
||||
shareButtonGhost: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
shareButtonGhostLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#475569',
|
||||
},
|
||||
shareButtonPrimary: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shareButtonPrimaryLabel: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
shareClose: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
shareCloseLabel: {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
},
|
||||
});
|
||||
@@ -25,11 +25,13 @@ const CIRCUMFERENCE_TYPES = [
|
||||
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
|
||||
];
|
||||
|
||||
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();
|
||||
@@ -293,7 +295,8 @@ export default function CircumferenceDetailScreen() {
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 60,
|
||||
paddingHorizontal: 20
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
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 { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export default function DeveloperScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const resetOnboardingStatus = async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.onboardingCompleted);
|
||||
Alert.alert('成功', '引导状态已重置,下次启动应用将重新显示引导页面');
|
||||
} catch (error) {
|
||||
console.error('重置引导状态失败:', error);
|
||||
Alert.alert('错误', '重置引导状态失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const developerItems = [
|
||||
{
|
||||
title: '日志',
|
||||
@@ -16,6 +28,12 @@ export default function DeveloperScreen() {
|
||||
icon: 'document-text-outline',
|
||||
onPress: () => router.push(ROUTES.DEVELOPER_LOGS),
|
||||
},
|
||||
{
|
||||
title: '重置引导状态',
|
||||
subtitle: '清除 onboarding 缓存,下次启动将重新显示引导页面',
|
||||
icon: 'refresh-outline',
|
||||
onPress: resetOnboardingStatus,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { log, logger, LogEntry } from '@/utils/logger';
|
||||
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';
|
||||
@@ -19,6 +20,7 @@ export default function LogsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showTestPanel, setShowTestPanel] = useState(false);
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
@@ -68,6 +70,10 @@ export default function LogsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNotifications = () => {
|
||||
setShowTestPanel(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
// 添加测试日志
|
||||
@@ -148,6 +154,9 @@ export default function LogsScreen() {
|
||||
</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>
|
||||
@@ -186,6 +195,13 @@ export default function LogsScreen() {
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 断食通知测试面板 */}
|
||||
{showTestPanel && (
|
||||
<FastingNotificationTestPanel
|
||||
onClose={() => setShowTestPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,7 @@ import { ThemedView } from '@/components/ThemedView';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
fetchActivityRingsForDate,
|
||||
fetchHourlyActiveCaloriesForDate,
|
||||
@@ -50,6 +51,7 @@ type WeekData = {
|
||||
};
|
||||
|
||||
export default function FitnessRingsDetailScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const colorScheme = useColorScheme();
|
||||
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
@@ -330,9 +332,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 +344,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>
|
||||
);
|
||||
@@ -500,7 +513,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 本周圆环横向滚动 */}
|
||||
@@ -731,23 +746,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: {
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Colors } from '@/constants/Colors';
|
||||
import { DEFAULT_IMAGE_FOOD } from '@/constants/Image';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useFoodLibrary, useFoodSearch } from '@/hooks/useFoodLibrary';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { addDietRecord, type CreateDietRecordDto } from '@/services/dietRecords';
|
||||
import { foodLibraryApi, type CreateCustomFoodDto } from '@/services/foodLibraryApi';
|
||||
import { fetchDailyNutritionData } from '@/store/nutritionSlice';
|
||||
import type { FoodItem, MealType, SelectedFoodItem } from '@/types/food';
|
||||
import { saveNutritionToHealthKit } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
@@ -22,7 +24,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
// 餐次映射保持不变
|
||||
@@ -36,6 +38,7 @@ const MEAL_TYPE_MAP = {
|
||||
};
|
||||
|
||||
export default function FoodLibraryScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||
const mealType = (params.mealType as MealType) || 'breakfast';
|
||||
@@ -176,7 +179,27 @@ export default function FoodLibraryScreen() {
|
||||
imageUrl: item.food.imageUrl,
|
||||
};
|
||||
|
||||
// 先保存到后端
|
||||
await addDietRecord(dietRecordData);
|
||||
|
||||
// 然后尝试同步到 HealthKit(非阻塞)
|
||||
// 提取蛋白质、脂肪和碳水化合物数据
|
||||
const { proteinGrams, fatGrams, carbohydrateGrams, mealTime } = dietRecordData;
|
||||
|
||||
if (proteinGrams !== undefined || fatGrams !== undefined || carbohydrateGrams !== undefined) {
|
||||
// 使用 catch 确保 HealthKit 同步失败不影响后端记录
|
||||
saveNutritionToHealthKit(
|
||||
{
|
||||
proteinGrams: proteinGrams || undefined,
|
||||
fatGrams: fatGrams || undefined,
|
||||
carbohydrateGrams: carbohydrateGrams || undefined
|
||||
},
|
||||
mealTime
|
||||
).catch(error => {
|
||||
// HealthKit 同步失败只记录日志,不影响用户体验
|
||||
console.error('HealthKit 营养数据同步失败(不影响记录):', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 记录成功后,刷新当天的营养数据
|
||||
@@ -272,7 +295,6 @@ export default function FoodLibraryScreen() {
|
||||
<HeaderBar
|
||||
title="食物库"
|
||||
onBack={() => router.back()}
|
||||
transparent={false}
|
||||
variant="elevated"
|
||||
right={
|
||||
<TouchableOpacity style={styles.customButton} onPress={handleCreateCustomFood}>
|
||||
@@ -281,6 +303,10 @@ export default function FoodLibraryScreen() {
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
|
||||
{/* 搜索框 */}
|
||||
<View style={styles.searchContainer}>
|
||||
<Ionicons name="search" size={20} color="#999" style={styles.searchIcon} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
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';
|
||||
@@ -73,6 +74,7 @@ const MEAL_TYPE_MAP = {
|
||||
};
|
||||
|
||||
export default function FoodAnalysisResultScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
imageUri?: string;
|
||||
@@ -264,6 +266,9 @@ export default function FoodAnalysisResultScreen() {
|
||||
title="分析结果"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||||
</View>
|
||||
@@ -287,7 +292,9 @@ export default function FoodAnalysisResultScreen() {
|
||||
transparent={true}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scrollContainer} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView style={styles.scrollContainer} contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}} showsVerticalScrollIndicator={false}>
|
||||
{/* 食物主图 */}
|
||||
<View style={styles.imageContainer}>
|
||||
{imageUri ? (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraType, CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { Image } from 'expo-image';
|
||||
@@ -8,24 +10,22 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
export default function FoodCameraScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{ mealType?: string }>();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
const [currentMealType, setCurrentMealType] = useState<MealType>(
|
||||
(params.mealType as MealType) || 'dinner'
|
||||
@@ -45,28 +45,34 @@ export default function FoodCameraScreen() {
|
||||
if (!permission) {
|
||||
// 权限仍在加载中
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title="食物拍摄"
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>正在加载相机...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
// 没有相机权限
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title="食物拍摄"
|
||||
onBack={() => router.back()}
|
||||
backColor='#ffffff'
|
||||
/>
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.permissionContainer}>
|
||||
<Ionicons name="camera-outline" size={64} color="#999" />
|
||||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||
@@ -80,7 +86,7 @@ export default function FoodCameraScreen() {
|
||||
<Text style={styles.permissionButtonText}>授权访问</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,10 +105,13 @@ export default function FoodCameraScreen() {
|
||||
});
|
||||
|
||||
if (photo) {
|
||||
// 跳转到食物识别页面
|
||||
// 先验证登录状态,再跳转到食物识别页面
|
||||
console.log('照片拍摄成功:', photo.uri);
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (isLoggedIn) {
|
||||
router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('拍照失败:', error);
|
||||
Alert.alert('拍照失败', '请重试');
|
||||
@@ -114,7 +123,7 @@ export default function FoodCameraScreen() {
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
@@ -123,8 +132,12 @@ export default function FoodCameraScreen() {
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const imageUri = result.assets[0].uri;
|
||||
console.log('从相册选择的照片:', imageUri);
|
||||
// 先验证登录状态,再跳转到食物识别页面
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (isLoggedIn) {
|
||||
router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择照片失败:', error);
|
||||
Alert.alert('选择失败', '请重试');
|
||||
@@ -152,7 +165,9 @@ export default function FoodCameraScreen() {
|
||||
transparent={true}
|
||||
backColor={'#fff'}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
{/* 主要内容区域 */}
|
||||
<View style={styles.contentContainer}>
|
||||
{/* 取景框容器 */}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { recognizeFood } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -22,6 +26,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FoodRecognitionScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
imageUri?: string;
|
||||
@@ -35,6 +40,11 @@ export default function FoodRecognitionScreen() {
|
||||
const [currentStep, setCurrentStep] = useState<'idle' | 'uploading' | 'recognizing' | 'completed' | 'failed'>('idle');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 添加认证和VIP服务相关hooks
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { handleServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
|
||||
// 动画引用
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
@@ -123,6 +133,28 @@ export default function FoodRecognitionScreen() {
|
||||
})
|
||||
]).start();
|
||||
|
||||
// 先验证登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查用户是否可以使用 AI 食物识别功能
|
||||
const canAccess = handleServiceAccess(
|
||||
() => {
|
||||
// 允许使用,继续执行识别流程
|
||||
},
|
||||
() => {
|
||||
// 不允许使用,显示会员付费弹窗
|
||||
openMembershipModal();
|
||||
}
|
||||
);
|
||||
|
||||
// 如果用户没有权限,直接返回
|
||||
if (!canAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setShowRecognitionProcess(true);
|
||||
setRecognitionLogs([]);
|
||||
@@ -217,6 +249,9 @@ export default function FoodRecognitionScreen() {
|
||||
title="食物识别"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片</Text>
|
||||
</View>
|
||||
@@ -232,7 +267,9 @@ export default function FoodRecognitionScreen() {
|
||||
/>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<ScrollView style={styles.contentContainer} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView style={styles.contentContainer} contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}} showsVerticalScrollIndicator={false}>
|
||||
{!showRecognitionProcess ? (
|
||||
// 确认界面
|
||||
<>
|
||||
|
||||
902
app/food/nutrition-analysis-history.tsx
Normal file
@@ -0,0 +1,902 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
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 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 || '获取历史记录失败';
|
||||
setError(errorMessage);
|
||||
Alert.alert('错误', errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 获取历史记录失败:', error);
|
||||
const errorMessage = '获取历史记录失败,请稍后重试';
|
||||
setError(errorMessage);
|
||||
Alert.alert('错误', 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 '成功';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 从营养数据中提取主要营养素的辅助函数
|
||||
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(
|
||||
'确认删除',
|
||||
'确定要删除这条营养分析记录吗?此操作无法撤销。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
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('成功', '记录已删除');
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 删除记录失败:', error);
|
||||
Alert.alert('错误', '删除失败,请稍后重试');
|
||||
} 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}>
|
||||
识别 {item.nutritionCount} 项营养素
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.recordDate}>
|
||||
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
|
||||
</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}>热量</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.protein && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>蛋白质</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.carbs && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>碳水</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.fat && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>脂肪</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 ? '收起详情' : '展开详情'}
|
||||
</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}>详细营养成分</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}>AI 模型: {item.aiModel}</Text>
|
||||
<Text style={styles.metaText}>服务提供商: {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}>暂无历史记录</Text>
|
||||
<Text style={styles.emptyStateSubtext}>开始识别营养成分表吧</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染错误状态
|
||||
const renderErrorState = () => (
|
||||
<View style={styles.errorState}>
|
||||
<Ionicons name="alert-circle-outline" size={64} color="#F44336" />
|
||||
<Text style={styles.errorStateText}>加载失败</Text>
|
||||
<Text style={styles.errorStateSubtext}>{error || '未知错误'}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
setLoading(true);
|
||||
fetchRecords(1, true);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>重试</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}>加载更多...</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="历史记录"
|
||||
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]}>
|
||||
全部
|
||||
</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]}>
|
||||
成功
|
||||
</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]}>
|
||||
失败
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 记录列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>加载历史记录...</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('YYYY年M月D日 HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</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',
|
||||
},
|
||||
});
|
||||
781
app/food/nutrition-label-analysis.tsx
Normal file
@@ -0,0 +1,781 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
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 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('权限不足', '需要相机权限才能拍摄成分表');
|
||||
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 || '分析失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
|
||||
setIsUploading(false);
|
||||
setIsAnalyzing(false);
|
||||
|
||||
// 显示错误提示
|
||||
Alert.alert(
|
||||
'分析失败',
|
||||
error.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="成分表分析"
|
||||
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}>开始分析</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}>拍摄或选择成分表照片</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}>拍摄</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 }]}>相册</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}>营养成分详细分析</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}>
|
||||
正在上传图片... {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}>正在分析成分表...</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('YYYY年M月D日 HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</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: 14,
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { preloadUserData } from '@/store/userSlice';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
|
||||
const ONBOARDING_COMPLETED_KEY = '@onboarding_completed';
|
||||
|
||||
export default function SplashScreen() {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const primaryColor = useThemeColor({}, 'primary');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { initializePushNotifications } = usePushNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
checkOnboardingStatus();
|
||||
@@ -19,23 +20,29 @@ export default function SplashScreen() {
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
// 先预加载用户数据,这样进入应用时就有正确的 token 状态
|
||||
console.log('开始预加载用户数据...');
|
||||
await preloadUserData();
|
||||
console.log('用户数据预加载完成');
|
||||
// 直接读取 onboarding 状态
|
||||
console.log('检查 onboarding 状态...');
|
||||
const onboardingCompletedStr = await AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted);
|
||||
const onboardingCompleted = onboardingCompletedStr === 'true';
|
||||
console.log('Onboarding 状态:', onboardingCompleted);
|
||||
|
||||
// const onboardingCompleted = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
|
||||
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
|
||||
console.log('开始初始化推送通知基础服务...');
|
||||
initializePushNotifications().catch((error) => {
|
||||
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
|
||||
});
|
||||
|
||||
// if (onboardingCompleted === 'true') {
|
||||
// router.replace('/(tabs)');
|
||||
// } else {
|
||||
// router.replace('/onboarding');
|
||||
// }
|
||||
// setIsLoading(false);
|
||||
// 根据状态决定跳转
|
||||
if (onboardingCompleted) {
|
||||
console.log('用户已完成引导,跳转到统计页面');
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
} else {
|
||||
console.log('用户未完成引导,跳转到引导页面');
|
||||
router.replace(ROUTES.ONBOARDING);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查引导状态或预加载用户数据失败:', error);
|
||||
// 如果出现错误,仍然进入应用,但可能会有状态更新
|
||||
console.error('检查引导状态失败:', error);
|
||||
// 如果出现错误,默认进入主应用(假设已完成引导)
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
}
|
||||
setIsLoading(false);
|
||||
|
||||
2941
app/medications/[medicationId].tsx
Normal file
1897
app/medications/add-medication.tsx
Normal file
943
app/medications/ai-camera.tsx
Normal file
@@ -0,0 +1,943 @@
|
||||
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { createMedicationRecognitionTask } from '@/services/medications';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
// 本地存储的 key,用于记录用户是否已经看过拍摄引导
|
||||
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
|
||||
|
||||
const captureSteps = [
|
||||
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
|
||||
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
|
||||
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
|
||||
] as const;
|
||||
|
||||
type CaptureKey = (typeof captureSteps)[number]['key'];
|
||||
|
||||
type Shot = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export default function MedicationAiCameraScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' });
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [facing, setFacing] = useState<'back' | 'front'>('back');
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
|
||||
front: null,
|
||||
side: null,
|
||||
aux: null,
|
||||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||
|
||||
// 动画控制:0 = 圆形拍摄按钮,1 = 展开为两个按钮
|
||||
const expandAnimation = useSharedValue(0);
|
||||
|
||||
// 首次进入时显示引导弹窗
|
||||
useEffect(() => {
|
||||
const checkAndShowGuide = async () => {
|
||||
try {
|
||||
// 从本地存储读取是否已经看过引导
|
||||
const hasSeenGuide = await getItem(MEDICATION_GUIDE_SEEN_KEY);
|
||||
|
||||
// 如果没有看过(返回 null 或 undefined),则显示引导弹窗
|
||||
if (!hasSeenGuide) {
|
||||
setShowGuideModal(true);
|
||||
// 标记为已看过,下次进入不再自动显示
|
||||
await setItem(MEDICATION_GUIDE_SEEN_KEY, 'true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] 检查引导状态失败', error);
|
||||
// 出错时为了更好的用户体验,还是显示引导
|
||||
setShowGuideModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkAndShowGuide();
|
||||
}, []);
|
||||
|
||||
const currentStep = captureSteps[currentStepIndex];
|
||||
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
|
||||
const allRequiredCaptured = Boolean(shots.front && shots.side);
|
||||
|
||||
// 当必需照片都拍摄完成后,触发展开动画
|
||||
useEffect(() => {
|
||||
if (allRequiredCaptured) {
|
||||
expandAnimation.value = withTiming(1, {
|
||||
duration: 350,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
} else {
|
||||
expandAnimation.value = withTiming(0, {
|
||||
duration: 300,
|
||||
easing: Easing.inOut(Easing.cubic),
|
||||
});
|
||||
}
|
||||
}, [allRequiredCaptured]);
|
||||
|
||||
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
|
||||
|
||||
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动
|
||||
const cameraHeight = useMemo(() => {
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
// 计算固定占用的高度(使用最大值确保布局稳定)
|
||||
const headerHeight = insets.top + 40; // HeaderBar 高度
|
||||
const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域:padding + badge + title + subtitle + gap
|
||||
const shotsRowHeight = 12 + 88; // shotsRow 区域:paddingTop + shotCard 高度
|
||||
// 固定使用展开状态的高度,确保布局不会跳动
|
||||
const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域(不包含动态变化部分)
|
||||
const margins = 12 + 12; // cameraCard 的上下边距
|
||||
|
||||
// 可用于相机的高度 = 屏幕高度 - 所有固定元素高度
|
||||
const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins;
|
||||
|
||||
// 确保最小高度为 300,最大不超过屏幕的 50%
|
||||
return Math.max(300, Math.min(availableHeight, screenHeight * 0.5));
|
||||
}, [insets.top, insets.bottom]);
|
||||
|
||||
const handleToggleCamera = () => {
|
||||
setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
|
||||
const handlePickFromAlbum = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets?.length) {
|
||||
const asset = result.assets[0];
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
goNextStep();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] pick image failed', error);
|
||||
Alert.alert('选择失败', '请重试或更换图片');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakePicture = async () => {
|
||||
if (!cameraRef.current) return;
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
|
||||
if (photo?.uri) {
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
goNextStep();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] take picture failed', error);
|
||||
Alert.alert('拍摄失败', '请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const goNextStep = () => {
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRecognition = async () => {
|
||||
// 检查必需照片是否完成
|
||||
if (!allRequiredCaptured) {
|
||||
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
|
||||
return;
|
||||
}
|
||||
|
||||
await startRecognition();
|
||||
};
|
||||
|
||||
const startRecognition = async () => {
|
||||
if (!shots.front || !shots.side) return;
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
|
||||
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const task = await createMedicationRecognitionTask({
|
||||
frontImageUrl: frontUpload.url,
|
||||
sideImageUrl: sideUpload.url,
|
||||
auxiliaryImageUrl: auxUpload?.url,
|
||||
});
|
||||
|
||||
router.replace({
|
||||
pathname: '/medications/ai-progress',
|
||||
params: {
|
||||
taskId: task.taskId,
|
||||
cover: frontUpload.url,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[MEDICATION_AI] recognize failed', error);
|
||||
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 动画翻转按钮组件
|
||||
const AnimatedToggleButton = ({
|
||||
expandAnimation,
|
||||
onPress,
|
||||
disabled,
|
||||
}: {
|
||||
expandAnimation: SharedValue<number>;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
// 翻转按钮的位置动画 - 展开时向右移出
|
||||
const toggleButtonStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, 100], // 向右移出屏幕
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const opacity = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 0.3],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={toggleButtonStyle}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// 动画拍摄按钮组件
|
||||
const AnimatedCaptureButton = ({
|
||||
allRequiredCaptured,
|
||||
expandAnimation,
|
||||
onCapture,
|
||||
onComplete,
|
||||
disabled,
|
||||
loading,
|
||||
}: {
|
||||
allRequiredCaptured: boolean;
|
||||
expandAnimation: SharedValue<number>;
|
||||
onCapture: () => void;
|
||||
onComplete: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}) => {
|
||||
// 单个拍摄按钮的缩放和透明度动画
|
||||
const singleButtonStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 0.3],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
transform: [{
|
||||
scale: interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 0.3],
|
||||
[1, 0.8],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
}],
|
||||
}));
|
||||
|
||||
// 左侧按钮的位置和透明度动画
|
||||
const leftButtonStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, -70], // 向左移动更多距离
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const opacity = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const scale = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0.8, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX }, { scale }],
|
||||
};
|
||||
});
|
||||
|
||||
// 右侧按钮的位置和透明度动画
|
||||
const rightButtonStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, 70], // 向右移动更多距离
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const opacity = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const scale = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0.8, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX }, { scale }],
|
||||
};
|
||||
});
|
||||
|
||||
// 容器整体向右平移的动画
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, 60], // 整体向右移动更多,与相册按钮保持距离
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
transform: [{ translateX }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.captureButtonContainer, containerStyle]}>
|
||||
{/* 未展开状态:圆形拍摄按钮 */}
|
||||
{!allRequiredCaptured && (
|
||||
<Animated.View style={[styles.singleCaptureWrapper, singleButtonStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.captureBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<View style={styles.captureOuterRing}>
|
||||
<View style={styles.captureInner} />
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
|
||||
<View style={styles.captureOuterRing}>
|
||||
<View style={styles.captureInner} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* 展开状态:两个分离的按钮 */}
|
||||
{allRequiredCaptured && (
|
||||
<>
|
||||
{/* 左侧:拍照按钮 */}
|
||||
<Animated.View style={[styles.splitButtonWrapper, leftButtonStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.splitButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(14, 165, 233, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* 右侧:完成按钮 */}
|
||||
<Animated.View style={[styles.splitButtonWrapper, rightButtonStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onComplete}
|
||||
disabled={disabled || loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.splitButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(16, 185, 129, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#10b981" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#10b981" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
|
||||
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
|
||||
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
|
||||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||
<Text style={styles.permissionTip}>授权后即可快速拍摄药品包装,自动识别信息</Text>
|
||||
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||||
<Text style={styles.permissionBtnText}>授权访问相机</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 引导说明弹窗 - 移到最外层 */}
|
||||
<MedicationPhotoGuideModal
|
||||
visible={showGuideModal}
|
||||
onClose={() => setShowGuideModal(false)}
|
||||
/>
|
||||
|
||||
<View style={styles.container}>
|
||||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar
|
||||
title="AI 用药识别"
|
||||
onBack={() => router.back()}
|
||||
transparent
|
||||
right={
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowGuideModal(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="查看拍摄说明"
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.infoButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
|
||||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
<View style={{ height: insets.top + 40 }} />
|
||||
|
||||
<View style={styles.topMeta}>
|
||||
<View style={styles.metaBadge}>
|
||||
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
|
||||
</View>
|
||||
<Text style={styles.metaTitle}>{currentStep.title}</Text>
|
||||
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraCard}>
|
||||
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
|
||||
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||||
style={styles.cameraOverlay}
|
||||
/>
|
||||
{coverPreview ? (
|
||||
<View style={styles.previewBadge}>
|
||||
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.shotsRow}>
|
||||
{captureSteps.map((step, index) => {
|
||||
const active = step.key === currentStep.key;
|
||||
const shot = shots[step.key];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={step.key}
|
||||
onPress={() => setCurrentStepIndex(index)}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.shotCard, active && styles.shotCardActive]}
|
||||
>
|
||||
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||||
{step.title}
|
||||
{!step.mandatory ? '(可选)' : ''}
|
||||
</Text>
|
||||
{shot ? (
|
||||
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.shotPlaceholder}>
|
||||
<Text style={styles.shotPlaceholderText}>未拍摄</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePickFromAlbum}
|
||||
disabled={creatingTask || uploading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<AnimatedCaptureButton
|
||||
allRequiredCaptured={allRequiredCaptured}
|
||||
expandAnimation={expandAnimation}
|
||||
onCapture={handleTakePicture}
|
||||
onComplete={handleStartRecognition}
|
||||
disabled={creatingTask}
|
||||
loading={creatingTask || uploading}
|
||||
/>
|
||||
|
||||
<AnimatedToggleButton
|
||||
expandAnimation={expandAnimation}
|
||||
onPress={handleToggleCamera}
|
||||
disabled={creatingTask}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
topMeta: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 6,
|
||||
},
|
||||
metaBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#e0f2fe',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
metaBadgeText: {
|
||||
color: '#0369a1',
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
metaTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
metaSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
},
|
||||
cameraCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 12,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
cameraFrame: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0b172a',
|
||||
height: 360,
|
||||
},
|
||||
cameraView: {
|
||||
flex: 1,
|
||||
},
|
||||
cameraOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
},
|
||||
previewBadge: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
shotsRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
shotCard: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f8fafc',
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
shotCardActive: {
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: '#ecfeff',
|
||||
},
|
||||
shotLabel: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
},
|
||||
shotLabelActive: {
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
shotThumb: {
|
||||
width: '100%',
|
||||
height: 70,
|
||||
borderRadius: 12,
|
||||
},
|
||||
shotPlaceholder: {
|
||||
height: 70,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#e2e8f0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shotPlaceholderText: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
bottomBar: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
height: 64,
|
||||
},
|
||||
singleCaptureWrapper: {
|
||||
position: 'absolute',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
fallbackCaptureBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
captureOuterRing: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
splitButtonWrapper: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
width: 110,
|
||||
height: 48,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
fallbackSplitButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
},
|
||||
splitButtonLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#0f172a',
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
fallbackSecondaryBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
primaryCta: {
|
||||
marginTop: 6,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
primaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
skipBtn: {
|
||||
alignSelf: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
skipText: {
|
||||
color: '#475569',
|
||||
fontSize: 13,
|
||||
},
|
||||
infoButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackInfoButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
permissionCard: {
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
permissionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
permissionTip: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
permissionBtn: {
|
||||
marginTop: 6,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
permissionBtnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
514
app/medications/ai-progress.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||||
import { MedicationRecognitionTask } from '@/types/medication';
|
||||
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 { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [
|
||||
{ key: 'analyzing_product', label: '正在进行产品分析...' },
|
||||
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' },
|
||||
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' },
|
||||
{ key: 'analyzing_effects', label: '正在生成安全建议...' },
|
||||
];
|
||||
|
||||
export default function MedicationAiProgressScreen() {
|
||||
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const navigatingRef = useRef(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 动画值:上下浮动和透明度
|
||||
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
const currentStepIndex = useMemo(() => {
|
||||
if (!task) return 0;
|
||||
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status);
|
||||
if (idx >= 0) return idx;
|
||||
if (task.status === 'completed') return STATUS_STEPS.length;
|
||||
return 0;
|
||||
}, [task]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!taskId || navigatingRef.current) return;
|
||||
try {
|
||||
const data = await getMedicationRecognitionStatus(taskId as string);
|
||||
setTask(data);
|
||||
setError(null);
|
||||
|
||||
// 识别成功,跳转到详情页
|
||||
if (data.status === 'completed' && data.result && !navigatingRef.current) {
|
||||
navigatingRef.current = true;
|
||||
// 清除轮询
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
router.replace({
|
||||
pathname: '/medications/[medicationId]',
|
||||
params: {
|
||||
medicationId: 'ai-draft',
|
||||
aiTaskId: data.taskId,
|
||||
cover: (cover as string) || data.result.photoUrl || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 识别失败,停止轮询并显示错误弹窗
|
||||
if (data.status === 'failed' && !navigatingRef.current) {
|
||||
navigatingRef.current = true;
|
||||
// 清除轮询
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
// 显示错误提示弹窗
|
||||
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄');
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[MEDICATION_AI] status failed', err);
|
||||
setError(err?.message || '查询失败,请稍后再试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重新拍摄
|
||||
const handleRetry = () => {
|
||||
setShowErrorModal(false);
|
||||
router.back();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
pollingTimerRef.current = setInterval(fetchStatus, 2400);
|
||||
return () => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// 启动浮动和闪烁动画 - 更快的动画速度
|
||||
useEffect(() => {
|
||||
// 上下浮动动画 - 加快速度
|
||||
const floatAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: -10,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// 透明度闪烁动画 - 加快速度,增加对比度
|
||||
const opacityAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0.4,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
floatAnimation.start();
|
||||
opacityAnimation.start();
|
||||
|
||||
return () => {
|
||||
floatAnimation.stop();
|
||||
opacityAnimation.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar title="识别中" onBack={() => router.back()} transparent />
|
||||
<View style={{ height: insets.top }} />
|
||||
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.heroImageWrapper}>
|
||||
{cover ? (
|
||||
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.heroPlaceholder} />
|
||||
)}
|
||||
|
||||
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
|
||||
{task?.status !== 'completed' && task?.status !== 'failed' && (
|
||||
<>
|
||||
{/* 深色半透明蒙版层,让点阵更清晰 */}
|
||||
<View style={styles.overlayMask} />
|
||||
|
||||
{/* 渐变蒙版边框,增加视觉层次 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']}
|
||||
style={styles.gradientBorder}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 点阵网格动画 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dottedGrid,
|
||||
{
|
||||
transform: [{ translateY: floatAnim }],
|
||||
opacity: opacityAnim,
|
||||
}
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: 11 }).map((_, idx) => (
|
||||
<View key={idx} style={styles.dotRow}>
|
||||
{Array.from({ length: 11 }).map((__, jdx) => (
|
||||
<View key={`${idx}-${jdx}`} style={styles.dot} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.progressRow}>
|
||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepList}>
|
||||
{STATUS_STEPS.map((step, index) => {
|
||||
const active = index === currentStepIndex;
|
||||
const done = index < currentStepIndex;
|
||||
return (
|
||||
<View key={step.key} style={styles.stepRow}>
|
||||
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
|
||||
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
|
||||
{step.label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{task?.status === 'completed' && (
|
||||
<View style={styles.stepRow}>
|
||||
<View style={[styles.bullet, styles.bulletDone]} />
|
||||
<Text style={[styles.stepLabel, styles.stepLabelDone]}>识别完成,正在载入详情...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.loadingBox}>
|
||||
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
</View>
|
||||
|
||||
{/* 识别提示弹窗 */}
|
||||
<Modal
|
||||
visible={showErrorModal}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={handleRetry}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={handleRetry}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={styles.errorModalContainer}
|
||||
>
|
||||
<View style={styles.errorModalContent}>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.errorModalTitle}>需要重新拍摄</Text>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<View style={styles.errorMessageBox}>
|
||||
<Text style={styles.errorMessageText}>{errorMessage}</Text>
|
||||
</View>
|
||||
|
||||
{/* 重新拍摄按钮 */}
|
||||
<TouchableOpacity
|
||||
onPress={handleRetry}
|
||||
activeOpacity={0.8}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.retryButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(14, 165, 233, 0.9)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryButtonGradient}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||
</LinearGradient>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.retryButton}>
|
||||
<LinearGradient
|
||||
colors={['#0ea5e9', '#06b6d4']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryButtonGradient}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
heroCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 24,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
heroImageWrapper: {
|
||||
height: 230,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
// 深色蒙版层,让点阵更清晰可见
|
||||
overlayMask: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.35)',
|
||||
},
|
||||
// 渐变边框效果
|
||||
gradientBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 18,
|
||||
},
|
||||
// 点阵网格容器
|
||||
dottedGrid: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
dotRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
// 单个点样式 - 更明亮和更大的发光效果
|
||||
dot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
},
|
||||
progressRow: {
|
||||
height: 8,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: 10,
|
||||
marginTop: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#0ea5e9',
|
||||
},
|
||||
progressText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
textAlign: 'right',
|
||||
},
|
||||
stepList: {
|
||||
marginTop: 24,
|
||||
marginHorizontal: 24,
|
||||
gap: 14,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
bullet: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
bulletActive: {
|
||||
backgroundColor: '#0ea5e9',
|
||||
},
|
||||
bulletDone: {
|
||||
backgroundColor: '#22c55e',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
},
|
||||
stepLabelActive: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '700',
|
||||
},
|
||||
stepLabelDone: {
|
||||
color: '#16a34a',
|
||||
fontWeight: '700',
|
||||
},
|
||||
loadingBox: {
|
||||
marginTop: 30,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
// Modal 样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
errorModalContainer: {
|
||||
width: SCREEN_WIDTH - 48,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 8,
|
||||
},
|
||||
errorModalContent: {
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorIconContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorIconCircle: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorModalTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorMessageBox: {
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 28,
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
errorMessageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
retryButtonGradient: {
|
||||
paddingVertical: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
672
app/medications/edit-frequency.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { updateMedicationAction } from '@/store/medicationsSlice';
|
||||
import type { RepeatPattern } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
|
||||
|
||||
// 辅助函数:从时间字符串创建 Date 对象
|
||||
const createDateFromTime = (time: string) => {
|
||||
try {
|
||||
if (!time || typeof time !== 'string') {
|
||||
console.warn('[MEDICATION] Invalid time string provided:', time);
|
||||
return new Date();
|
||||
}
|
||||
|
||||
const parts = time.split(':');
|
||||
if (parts.length !== 2) {
|
||||
console.warn('[MEDICATION] Invalid time format:', time);
|
||||
return new Date();
|
||||
}
|
||||
|
||||
const hour = parseInt(parts[0], 10);
|
||||
const minute = parseInt(parts[1], 10);
|
||||
|
||||
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
console.warn('[MEDICATION] Invalid time values:', { hour, minute });
|
||||
return new Date();
|
||||
}
|
||||
|
||||
const next = new Date();
|
||||
next.setHours(hour, minute, 0, 0);
|
||||
|
||||
if (isNaN(next.getTime())) {
|
||||
console.error('[MEDICATION] Failed to create valid date');
|
||||
return new Date();
|
||||
}
|
||||
|
||||
return next;
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] Error in createDateFromTime:', error);
|
||||
return new Date();
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:格式化时间
|
||||
const formatTime = (date: Date) => dayjs(date).format('HH:mm');
|
||||
|
||||
// 辅助函数:获取默认时间
|
||||
const getDefaultTimeByIndex = (index: number) => {
|
||||
return DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
|
||||
};
|
||||
|
||||
export default function EditMedicationFrequencyScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
medicationId?: string;
|
||||
medicationName?: string;
|
||||
repeatPattern?: RepeatPattern;
|
||||
timesPerDay?: string;
|
||||
medicationTimes?: string;
|
||||
}>();
|
||||
|
||||
const medicationId = Array.isArray(params.medicationId) ? params.medicationId[0] : params.medicationId;
|
||||
const medicationName = Array.isArray(params.medicationName) ? params.medicationName[0] : params.medicationName;
|
||||
const initialRepeatPattern = (Array.isArray(params.repeatPattern) ? params.repeatPattern[0] : params.repeatPattern) as RepeatPattern || 'daily';
|
||||
const initialTimesPerDay = parseInt(Array.isArray(params.timesPerDay) ? params.timesPerDay[0] : params.timesPerDay || '1', 10);
|
||||
const initialTimes = params.medicationTimes
|
||||
? (Array.isArray(params.medicationTimes) ? params.medicationTimes[0] : params.medicationTimes).split(',')
|
||||
: ['08:00'];
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [repeatPattern, setRepeatPattern] = useState<RepeatPattern>(initialRepeatPattern);
|
||||
const [timesPerDay, setTimesPerDay] = useState(initialTimesPerDay);
|
||||
const [medicationTimes, setMedicationTimes] = useState<string[]>(initialTimes);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 时间选择器相关状态
|
||||
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
||||
const [timePickerDate, setTimePickerDate] = useState<Date>(new Date());
|
||||
const [editingTimeIndex, setEditingTimeIndex] = useState<number | null>(null);
|
||||
|
||||
// 根据 timesPerDay 动态调整 medicationTimes
|
||||
useEffect(() => {
|
||||
setMedicationTimes((prev) => {
|
||||
if (timesPerDay > prev.length) {
|
||||
const next = [...prev];
|
||||
while (next.length < timesPerDay) {
|
||||
next.push(getDefaultTimeByIndex(next.length));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
if (timesPerDay < prev.length) {
|
||||
return prev.slice(0, timesPerDay);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [timesPerDay]);
|
||||
|
||||
// 打开时间选择器
|
||||
const openTimePicker = useCallback(
|
||||
(index?: number) => {
|
||||
try {
|
||||
if (typeof index === 'number') {
|
||||
if (index >= 0 && index < medicationTimes.length) {
|
||||
setEditingTimeIndex(index);
|
||||
setTimePickerDate(createDateFromTime(medicationTimes[index]));
|
||||
} else {
|
||||
console.error('[MEDICATION] Invalid time index:', index);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setEditingTimeIndex(null);
|
||||
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
|
||||
}
|
||||
setTimePickerVisible(true);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] Error in openTimePicker:', error);
|
||||
}
|
||||
},
|
||||
[medicationTimes]
|
||||
);
|
||||
|
||||
// 确认时间选择
|
||||
const confirmTime = useCallback(
|
||||
(date: Date) => {
|
||||
try {
|
||||
if (!date || isNaN(date.getTime())) {
|
||||
console.error('[MEDICATION] Invalid date provided to confirmTime');
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = formatTime(date);
|
||||
setMedicationTimes((prev) => {
|
||||
if (editingTimeIndex == null) {
|
||||
return [...prev, nextValue];
|
||||
}
|
||||
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
|
||||
});
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] Error in confirmTime:', error);
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
}
|
||||
},
|
||||
[editingTimeIndex]
|
||||
);
|
||||
|
||||
// 删除时间
|
||||
const removeTime = useCallback((index: number) => {
|
||||
setMedicationTimes((prev) => {
|
||||
if (prev.length === 1) {
|
||||
return prev; // 至少保留一个时间
|
||||
}
|
||||
return prev.filter((_, idx) => idx !== index);
|
||||
});
|
||||
// 同时更新 timesPerDay
|
||||
setTimesPerDay((prev) => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
// 添加时间
|
||||
const addTime = useCallback(() => {
|
||||
openTimePicker();
|
||||
// 同时更新 timesPerDay
|
||||
setTimesPerDay((prev) => prev + 1);
|
||||
}, [openTimePicker]);
|
||||
|
||||
// 保存修改
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!medicationId || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medicationId,
|
||||
repeatPattern,
|
||||
timesPerDay,
|
||||
medicationTimes,
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
router.back();
|
||||
} catch (err) {
|
||||
console.error('更新频率失败', err);
|
||||
Alert.alert('更新失败', '更新服药频率时出现问题,请稍后重试。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [dispatch, medicationId, medicationTimes, repeatPattern, router, saving, timesPerDay]);
|
||||
|
||||
const frequencyLabel = useMemo(() => {
|
||||
switch (repeatPattern) {
|
||||
case 'daily':
|
||||
return `每日 ${timesPerDay} 次`;
|
||||
case 'weekly':
|
||||
return `每周 ${timesPerDay} 次`;
|
||||
default:
|
||||
return `自定义 · ${timesPerDay} 次/日`;
|
||||
}
|
||||
}, [repeatPattern, timesPerDay]);
|
||||
|
||||
if (!medicationId) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar title="编辑服药频率" variant="minimal" />
|
||||
<View style={styles.centered}>
|
||||
<ThemedText style={styles.emptyText}>缺少必要参数</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar
|
||||
title="编辑服药频率"
|
||||
variant="minimal"
|
||||
transparent
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{
|
||||
paddingTop: insets.top + 72,
|
||||
paddingBottom: Math.max(insets.bottom, 16) + 120,
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 药品名称提示 */}
|
||||
{medicationName && (
|
||||
<View style={[styles.medicationNameCard, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name="medical" size={20} color={colors.primary} />
|
||||
<ThemedText style={[styles.medicationNameText, { color: colors.text }]}>
|
||||
{medicationName}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 频率选择 */}
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
服药频率
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
|
||||
设置每日服药次数
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.pickerRow}>
|
||||
<View style={styles.pickerColumn}>
|
||||
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
|
||||
重复模式
|
||||
</ThemedText>
|
||||
<Picker
|
||||
selectedValue={repeatPattern}
|
||||
onValueChange={(value) => setRepeatPattern(value as RepeatPattern)}
|
||||
itemStyle={styles.pickerItem}
|
||||
style={styles.picker}
|
||||
>
|
||||
<Picker.Item label="每日" value="daily" />
|
||||
{/* <Picker.Item label="每周" value="weekly" />
|
||||
<Picker.Item label="自定义" value="custom" /> */}
|
||||
</Picker>
|
||||
</View>
|
||||
<View style={styles.pickerColumn}>
|
||||
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
|
||||
每日次数
|
||||
</ThemedText>
|
||||
<Picker
|
||||
selectedValue={timesPerDay}
|
||||
onValueChange={(value) => setTimesPerDay(Number(value))}
|
||||
itemStyle={styles.pickerItem}
|
||||
style={styles.picker}
|
||||
>
|
||||
{TIMES_PER_DAY_OPTIONS.map((times) => (
|
||||
<Picker.Item
|
||||
key={times}
|
||||
label={`${times} 次`}
|
||||
value={times}
|
||||
/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.frequencySummary, { backgroundColor: colors.surface }]}>
|
||||
<Ionicons name="repeat" size={18} color={colors.primary} />
|
||||
<ThemedText style={[styles.frequencySummaryText, { color: colors.text }]}>
|
||||
{frequencyLabel}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 提醒时间列表 */}
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={[styles.sectionTitle, { color: colors.text }]}>
|
||||
每日提醒时间
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.sectionDescription, { color: colors.textSecondary }]}>
|
||||
添加并管理每天的提醒时间
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.timeList}>
|
||||
{medicationTimes.map((time, index) => (
|
||||
<View
|
||||
key={`${time}-${index}`}
|
||||
style={[
|
||||
styles.timeItem,
|
||||
{
|
||||
borderColor: `${colors.border}80`,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity style={styles.timeValue} onPress={() => openTimePicker(index)}>
|
||||
<Ionicons name="time" size={20} color={colors.primary} />
|
||||
<ThemedText style={[styles.timeText, { color: colors.text }]}>{time}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<Pressable
|
||||
onPress={() => removeTime(index)}
|
||||
disabled={medicationTimes.length === 1}
|
||||
hitSlop={12}
|
||||
>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={medicationTimes.length === 1 ? `${colors.border}80` : colors.textSecondary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.addTimeButton, { borderColor: colors.primary }]}
|
||||
onPress={addTime}
|
||||
>
|
||||
<Ionicons name="add" size={18} color={colors.primary} />
|
||||
<ThemedText style={[styles.addTimeLabel, { color: colors.primary }]}>
|
||||
添加时间
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部保存按钮 */}
|
||||
<View
|
||||
style={[
|
||||
styles.footerBar,
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 18),
|
||||
backgroundColor: colors.pageBackgroundEmphasis,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.saveButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor={`rgba(122, 90, 248, 0.8)`}
|
||||
isInteractive={!saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#fff" />
|
||||
<ThemedText style={styles.saveButtonText}>保存修改</ThemedText>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.saveButton, styles.fallbackSaveButton]}>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#fff" />
|
||||
<ThemedText style={styles.saveButtonText}>保存修改</ThemedText>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 时间选择器 Modal */}
|
||||
<Modal
|
||||
visible={timePickerVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => {
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.pickerBackdrop}
|
||||
onPress={() => {
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
}}
|
||||
/>
|
||||
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
|
||||
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
|
||||
{editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'}
|
||||
</ThemedText>
|
||||
<DateTimePicker
|
||||
value={timePickerDate}
|
||||
mode="time"
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setTimePickerDate(date);
|
||||
} else {
|
||||
if (event.type === 'set' && date) {
|
||||
confirmTime(date);
|
||||
} else {
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.pickerActions}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setTimePickerVisible(false);
|
||||
setEditingTimeIndex(null);
|
||||
}}
|
||||
style={[styles.pickerBtn, { borderColor: colors.border }]}
|
||||
>
|
||||
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
|
||||
取消
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => confirmTime(timePickerDate)}
|
||||
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
|
||||
确定
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 32,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
medicationNameCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
medicationNameText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
section: {
|
||||
gap: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
pickerRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
},
|
||||
pickerColumn: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
pickerLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
picker: {
|
||||
width: '100%',
|
||||
height: 150,
|
||||
},
|
||||
pickerItem: {
|
||||
fontSize: 18,
|
||||
height: 150,
|
||||
},
|
||||
frequencySummary: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
frequencySummaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeList: {
|
||||
gap: 12,
|
||||
},
|
||||
timeItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
timeValue: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
addTimeButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
addTimeLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
footerBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(15,23,42,0.06)',
|
||||
},
|
||||
saveButton: {
|
||||
height: 56,
|
||||
borderRadius: 24,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackSaveButton: {
|
||||
backgroundColor: '#7a5af8',
|
||||
shadowColor: 'rgba(122, 90, 248, 0.4)',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 20,
|
||||
elevation: 6,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
pickerBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
pickerSheet: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
pickerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
pickerActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
pickerBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
pickerBtnPrimary: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
pickerBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
554
app/medications/manage-medications.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
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 {
|
||||
fetchMedications,
|
||||
selectMedications,
|
||||
selectMedicationsLoading,
|
||||
updateMedicationAction,
|
||||
} from '@/store/medicationsSlice';
|
||||
import { selectUserProfile } from '@/store/userSlice';
|
||||
import type { Medication, MedicationForm } from '@/types/medication';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
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, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type FilterType = 'all' | 'active' | 'inactive';
|
||||
|
||||
// 这些常量将在组件内部定义,以便使用翻译函数
|
||||
|
||||
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
|
||||
|
||||
export default function ManageMedicationsScreen() {
|
||||
const { t } = useI18n();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colors = Colors[theme];
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const insets = useSafeAreaInsets();
|
||||
const medications = useAppSelector(selectMedications);
|
||||
const loading = useAppSelector(selectMedicationsLoading);
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
|
||||
const [pendingMedicationId, setPendingMedicationId] = useState<string | null>(null);
|
||||
const [deactivateSheetVisible, setDeactivateSheetVisible] = useState(false);
|
||||
const [medicationToDeactivate, setMedicationToDeactivate] = useState<Medication | null>(null);
|
||||
const [deactivateLoading, setDeactivateLoading] = useState(false);
|
||||
|
||||
const listLoading = loading.medications && medications.length === 0;
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(fetchMedications());
|
||||
}, [dispatch])
|
||||
);
|
||||
|
||||
// 优化:使用更精确的依赖项,只有当药品数量或激活状态改变时才重新计算
|
||||
const medicationsHash = useMemo(() => {
|
||||
return medications.map(m => `${m.id}-${m.isActive}`).join('|');
|
||||
}, [medications]);
|
||||
|
||||
const counts = useMemo<Record<FilterType, number>>(() => {
|
||||
const active = medications.filter((med) => med.isActive).length;
|
||||
const inactive = medications.length - active;
|
||||
return {
|
||||
all: medications.length,
|
||||
active,
|
||||
inactive,
|
||||
};
|
||||
}, [medicationsHash]);
|
||||
|
||||
const filteredMedications = useMemo(() => {
|
||||
switch (activeFilter) {
|
||||
case 'active':
|
||||
return medications.filter((med) => med.isActive);
|
||||
case 'inactive':
|
||||
return medications.filter((med) => !med.isActive);
|
||||
default:
|
||||
return medications;
|
||||
}
|
||||
}, [activeFilter, medicationsHash]);
|
||||
|
||||
const handleToggleMedication = useCallback(
|
||||
async (medication: Medication, nextValue: boolean) => {
|
||||
if (pendingMedicationId) return;
|
||||
|
||||
// 如果是关闭激活状态,显示确认弹窗
|
||||
if (!nextValue) {
|
||||
setMedicationToDeactivate(medication);
|
||||
setDeactivateSheetVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是开启激活状态,直接执行
|
||||
try {
|
||||
setPendingMedicationId(medication.id);
|
||||
await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medication.id,
|
||||
isActive: nextValue,
|
||||
})
|
||||
).unwrap();
|
||||
} catch (error) {
|
||||
console.error('更新药物状态失败', error);
|
||||
Alert.alert(t('medications.manage.toggleError.title'), t('medications.manage.toggleError.message'));
|
||||
} finally {
|
||||
setPendingMedicationId(null);
|
||||
}
|
||||
},
|
||||
[dispatch, pendingMedicationId]
|
||||
);
|
||||
|
||||
const handleDeactivateMedication = useCallback(async () => {
|
||||
if (!medicationToDeactivate || deactivateLoading) return;
|
||||
|
||||
try {
|
||||
setDeactivateLoading(true);
|
||||
setDeactivateSheetVisible(false); // 立即关闭确认对话框
|
||||
|
||||
await dispatch(
|
||||
updateMedicationAction({
|
||||
id: medicationToDeactivate.id,
|
||||
isActive: false,
|
||||
})
|
||||
).unwrap();
|
||||
} catch (error) {
|
||||
console.error('停用药物失败', error);
|
||||
Alert.alert(t('medications.manage.deactivate.error.title'), t('medications.manage.deactivate.error.message'));
|
||||
} finally {
|
||||
setDeactivateLoading(false);
|
||||
setMedicationToDeactivate(null);
|
||||
}
|
||||
}, [dispatch, medicationToDeactivate, deactivateLoading]);
|
||||
|
||||
// 创建独立的药品卡片组件,使用 React.memo 优化渲染
|
||||
const MedicationCard = React.memo(({ medication, onPress }: { medication: Medication; onPress: () => void }) => {
|
||||
// 使用翻译函数获取剂型标签
|
||||
const FORM_LABELS: Record<MedicationForm, string> = {
|
||||
capsule: t('medications.manage.formLabels.capsule'),
|
||||
pill: t('medications.manage.formLabels.pill'),
|
||||
injection: t('medications.manage.formLabels.injection'),
|
||||
spray: t('medications.manage.formLabels.spray'),
|
||||
drop: t('medications.manage.formLabels.drop'),
|
||||
syrup: t('medications.manage.formLabels.syrup'),
|
||||
other: t('medications.manage.formLabels.other'),
|
||||
};
|
||||
|
||||
const dosageLabel = `${medication.dosageValue} ${medication.dosageUnit || ''} ${FORM_LABELS[medication.form] ?? ''}`.trim();
|
||||
const frequencyLabel = `${medication.repeatPattern === 'daily' ? t('medications.manage.frequency.daily') : medication.repeatPattern === 'weekly' ? t('medications.manage.frequency.weekly') : t('medications.manage.frequency.custom')} | ${dosageLabel}`;
|
||||
const startDateLabel = dayjs(medication.startDate).isValid()
|
||||
? dayjs(medication.startDate).format('M月D日')
|
||||
: t('medications.manage.unknownDate');
|
||||
const reminderLabel = medication.medicationTimes?.length
|
||||
? medication.medicationTimes.join('、')
|
||||
: `${medication.timesPerDay} ${t('medications.manage.cardMeta.reminderNotSet')}`;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.surface }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.cardInfo}>
|
||||
<Image
|
||||
source={medication.photoUrl ? { uri: medication.photoUrl } : DEFAULT_IMAGE}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View style={styles.cardTexts}>
|
||||
<ThemedText style={styles.cardTitle}>{medication.name}</ThemedText>
|
||||
<ThemedText style={[styles.cardMeta, { color: colors.textSecondary }]}>
|
||||
{frequencyLabel}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.cardMeta, { color: colors.textMuted }]}>
|
||||
{t('medications.manage.cardMeta', { date: startDateLabel, reminder: reminderLabel })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.switchContainer}>
|
||||
<Switch
|
||||
value={medication.isActive}
|
||||
onValueChange={(value) => handleToggleMedication(medication, value)}
|
||||
disabled={pendingMedicationId === medication.id}
|
||||
trackColor={{ false: '#D9D9D9', true: colors.primary }}
|
||||
thumbColor={medication.isActive ? '#fff' : '#fff'}
|
||||
ios_backgroundColor="#D9D9D9"
|
||||
/>
|
||||
{pendingMedicationId === medication.id && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary}
|
||||
style={styles.switchLoading}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只有当药品的 isActive 状态或 ID 改变时才重新渲染
|
||||
return (
|
||||
prevProps.medication.id === nextProps.medication.id &&
|
||||
prevProps.medication.isActive === nextProps.medication.isActive &&
|
||||
prevProps.medication.name === nextProps.medication.name &&
|
||||
prevProps.medication.photoUrl === nextProps.medication.photoUrl
|
||||
);
|
||||
});
|
||||
|
||||
MedicationCard.displayName = 'MedicationCard';
|
||||
|
||||
const handleOpenMedicationDetails = useCallback((medicationId: string) => {
|
||||
router.push({
|
||||
pathname: '/medications/[medicationId]',
|
||||
params: { medicationId },
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderMedicationCard = useCallback(
|
||||
(medication: Medication) => {
|
||||
return (
|
||||
<MedicationCard
|
||||
key={medication.id}
|
||||
medication={medication}
|
||||
onPress={() => handleOpenMedicationDetails(medication.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleToggleMedication, pendingMedicationId, colors, handleOpenMedicationDetails]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<HeaderBar
|
||||
title={t('medications.manage.title')}
|
||||
onBack={() => router.back()}
|
||||
variant="minimal"
|
||||
transparent
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
{
|
||||
paddingTop: safeAreaTop , // HeaderBar高度 + 额外间距
|
||||
paddingBottom: insets.bottom + 32
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.pageHeader}>
|
||||
<View>
|
||||
<ThemedText style={styles.title}>{t('medications.greeting', { name: userProfile.name || '朋友' })}</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||
{t('medications.manage.subtitle')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={() => router.push('/medications/add-medication')}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.addButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="plus" size={20} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.addButton, styles.fallbackAddButton]}>
|
||||
<IconSymbol name="plus" size={20} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.segmented, { backgroundColor: colors.surface }]}>
|
||||
{[
|
||||
{ key: 'all' as FilterType, label: t('medications.manage.filters.all') },
|
||||
{ key: 'active' as FilterType, label: t('medications.manage.filters.active') },
|
||||
{ key: 'inactive' as FilterType, label: t('medications.manage.filters.inactive') },
|
||||
].map((filter) => {
|
||||
const isActive = filter.key === activeFilter;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={filter.key}
|
||||
style={[
|
||||
styles.segmentButton,
|
||||
isActive && { backgroundColor: colors.primary },
|
||||
]}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setActiveFilter(filter.key)}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.segmentLabel,
|
||||
{ color: isActive ? colors.onPrimary : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{filter.label}
|
||||
</ThemedText>
|
||||
<View
|
||||
style={[
|
||||
styles.segmentBadge,
|
||||
{
|
||||
backgroundColor: isActive
|
||||
? colors.onPrimary
|
||||
: `${colors.primary}15`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.segmentBadgeLabel,
|
||||
{ color: isActive ? colors.primary : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{counts[filter.key] ?? 0}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{listLoading ? (
|
||||
<View style={[styles.loading, { backgroundColor: colors.surface }]}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
<ThemedText style={styles.loadingText}>{t('medications.manage.loading')}</ThemedText>
|
||||
</View>
|
||||
) : filteredMedications.length === 0 ? (
|
||||
<View style={[styles.empty, { backgroundColor: colors.surface }]}>
|
||||
<Image source={DEFAULT_IMAGE} style={styles.emptyImage} contentFit="contain" />
|
||||
<ThemedText style={styles.emptyTitle}>{t('medications.manage.empty.title')}</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: colors.textSecondary }]}>
|
||||
{t('medications.manage.empty.subtitle')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.list}>{filteredMedications.map(renderMedicationCard)}</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 停用药品确认弹窗 */}
|
||||
{medicationToDeactivate ? (
|
||||
<ConfirmationSheet
|
||||
visible={deactivateSheetVisible}
|
||||
onClose={() => {
|
||||
setDeactivateSheetVisible(false);
|
||||
setMedicationToDeactivate(null);
|
||||
}}
|
||||
onConfirm={handleDeactivateMedication}
|
||||
title={t('medications.manage.deactivate.title', { name: medicationToDeactivate.name })}
|
||||
description={t('medications.manage.deactivate.description')}
|
||||
confirmText={t('medications.manage.deactivate.confirm')}
|
||||
cancelText={t('medications.manage.deactivate.cancel')}
|
||||
destructive
|
||||
loading={deactivateLoading}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
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,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 20,
|
||||
},
|
||||
pageHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
addButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackAddButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
segmented: {
|
||||
flexDirection: 'row',
|
||||
padding: 6,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 10,
|
||||
gap: 8,
|
||||
},
|
||||
segmentLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentBadge: {
|
||||
minWidth: 28,
|
||||
paddingHorizontal: 8,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentBadgeLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
list: {
|
||||
gap: 14,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 22,
|
||||
padding: 14,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 1,
|
||||
},
|
||||
cardInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
},
|
||||
cardImage: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F2F2F2',
|
||||
},
|
||||
cardTexts: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardMeta: {
|
||||
fontSize: 13,
|
||||
},
|
||||
loading: {
|
||||
borderRadius: 22,
|
||||
paddingVertical: 32,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
empty: {
|
||||
borderRadius: 22,
|
||||
paddingVertical: 32,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
emptyImage: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
switchContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
switchLoading: {
|
||||
position: 'absolute',
|
||||
marginLeft: 30, // 确保加载指示器显示在开关旁边
|
||||
},
|
||||
});
|
||||
@@ -1,289 +0,0 @@
|
||||
import { MoodHistoryCard } from '@/components/MoodHistoryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import {
|
||||
fetchMoodHistory,
|
||||
fetchMoodStatistics,
|
||||
selectMoodLoading,
|
||||
selectMoodRecords,
|
||||
selectMoodStatistics
|
||||
} from '@/store/moodSlice';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
export default function MoodStatisticsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const { isLoggedIn } = useAuthGuard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const moodRecords = useAppSelector(selectMoodRecords);
|
||||
const statistics = useAppSelector(selectMoodStatistics);
|
||||
const loading = useAppSelector(selectMoodLoading);
|
||||
|
||||
// 获取最近30天的心情数据
|
||||
const loadMoodData = async () => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
const endDate = dayjs().format('YYYY-MM-DD');
|
||||
const startDate = dayjs().subtract(30, 'days').format('YYYY-MM-DD');
|
||||
|
||||
// 并行加载历史记录和统计数据
|
||||
await Promise.all([
|
||||
dispatch(fetchMoodHistory({ startDate, endDate })),
|
||||
dispatch(fetchMoodStatistics({ startDate, endDate }))
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('加载心情数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMoodData();
|
||||
}, [isLoggedIn, dispatch]);
|
||||
|
||||
// 将 moodRecords 转换为数组格式
|
||||
const moodCheckins = Object.values(moodRecords).flat();
|
||||
|
||||
// 使用统一的渐变背景色
|
||||
const backgroundGradientColors = [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd] as const;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={backgroundGradientColors}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={styles.loginPrompt}>请先登录查看心情统计</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={backgroundGradientColors}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<HeaderBar
|
||||
title="心情统计"
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
transparent={true}
|
||||
tone="light"
|
||||
/>
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
|
||||
{loading.history || loading.statistics ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colorTokens.primary} />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* 统计概览 */}
|
||||
{statistics && (
|
||||
<View style={styles.statsOverview}>
|
||||
<Text style={styles.sectionTitle}>统计概览</Text>
|
||||
<View style={styles.statsGrid}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{statistics.totalCheckins}</Text>
|
||||
<Text style={styles.statLabel}>总打卡次数</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{statistics.averageIntensity.toFixed(1)}</Text>
|
||||
<Text style={styles.statLabel}>平均强度</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>
|
||||
{statistics.mostFrequentMood ? statistics.moodDistribution[statistics.mostFrequentMood] || 0 : 0}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>最常见心情</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 心情历史记录 */}
|
||||
<MoodHistoryCard
|
||||
moodCheckins={moodCheckins}
|
||||
title="最近30天心情记录"
|
||||
/>
|
||||
|
||||
{/* 心情分布 */}
|
||||
{statistics && (
|
||||
<View style={styles.distributionContainer}>
|
||||
<Text style={styles.sectionTitle}>心情分布</Text>
|
||||
<View style={styles.distributionList}>
|
||||
{Object.entries(statistics.moodDistribution)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([moodType, count]) => (
|
||||
<View key={moodType} style={styles.distributionItem}>
|
||||
<Text style={styles.moodType}>{moodType}</Text>
|
||||
<View style={styles.countContainer}>
|
||||
<Text style={styles.count}>{count}</Text>
|
||||
<Text style={styles.percentage}>
|
||||
({((count / statistics.totalCheckins) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loginPrompt: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statsOverview: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
statsGrid: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
},
|
||||
distributionContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
distributionList: {
|
||||
gap: 12,
|
||||
},
|
||||
distributionItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
moodType: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#192126',
|
||||
},
|
||||
countContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
count: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
percentage: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useMoodData } from '@/hooks/useMoodData';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMoodOptions } from '@/services/moodCheckins';
|
||||
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -8,7 +9,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Dimensions, Image, SafeAreaView,
|
||||
Dimensions, Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -60,6 +61,7 @@ const generateCalendarData = (targetDate: Date) => {
|
||||
};
|
||||
|
||||
export default function MoodCalendarScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const params = useLocalSearchParams();
|
||||
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
||||
|
||||
@@ -231,7 +233,7 @@ export default function MoodCalendarScreen() {
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.safeArea}>
|
||||
<HeaderBar
|
||||
title="心情日历"
|
||||
onBack={() => router.back()}
|
||||
@@ -240,7 +242,9 @@ export default function MoodCalendarScreen() {
|
||||
tone="light"
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
<ScrollView style={styles.content} contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}}>
|
||||
{/* 日历视图 */}
|
||||
<View style={styles.calendar}>
|
||||
{/* 月份导航 */}
|
||||
@@ -363,7 +367,7 @@ export default function MoodCalendarScreen() {
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
|
||||
import {
|
||||
createMoodRecord,
|
||||
@@ -28,9 +29,10 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function MoodEditScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const params = useLocalSearchParams();
|
||||
@@ -179,7 +181,7 @@ export default function MoodEditScreen() {
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<View style={styles.safeArea} >
|
||||
<HeaderBar
|
||||
title={existingMood ? '编辑心情' : '记录心情'}
|
||||
onBack={() => router.back()}
|
||||
@@ -196,7 +198,9 @@ export default function MoodEditScreen() {
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.content}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
@@ -299,7 +303,7 @@ export default function MoodEditScreen() {
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
435
app/notification-settings.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
getMedicationReminderEnabled,
|
||||
getMoodReminderEnabled,
|
||||
getNotificationEnabled,
|
||||
getNutritionReminderEnabled,
|
||||
setMedicationReminderEnabled,
|
||||
setMoodReminderEnabled,
|
||||
setNotificationEnabled,
|
||||
setNutritionReminderEnabled
|
||||
} from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useFocusEffect } from 'expo-router';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, View } from 'react-native';
|
||||
|
||||
export default function NotificationSettingsScreen() {
|
||||
const safeAreaTop = useSafeAreaTop(60);
|
||||
const { t } = useI18n();
|
||||
const { requestPermission, sendNotification } = useNotifications();
|
||||
|
||||
// 通知设置状态
|
||||
const [notificationEnabled, setNotificationEnabledState] = useState(false);
|
||||
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
|
||||
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
|
||||
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载通知设置
|
||||
const loadNotificationSettings = useCallback(async () => {
|
||||
try {
|
||||
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([
|
||||
getNotificationEnabled(),
|
||||
getMedicationReminderEnabled(),
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
]);
|
||||
setNotificationEnabledState(notification);
|
||||
setMedicationReminderEnabledState(medicationReminder);
|
||||
setNutritionReminderEnabledState(nutritionReminder);
|
||||
setMoodReminderEnabledState(moodReminder);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notification settings:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 页面聚焦时加载设置
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadNotificationSettings();
|
||||
}, [loadNotificationSettings])
|
||||
);
|
||||
|
||||
// 处理总通知开关变化
|
||||
const handleNotificationToggle = async (value: boolean) => {
|
||||
if (value) {
|
||||
try {
|
||||
// 先检查系统权限
|
||||
const status = await requestPermission();
|
||||
if (status === 'granted') {
|
||||
// 系统权限获取成功,保存用户偏好设置
|
||||
await setNotificationEnabled(true);
|
||||
setNotificationEnabledState(true);
|
||||
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.notificationsEnabled.title'),
|
||||
body: t('notificationSettings.alerts.notificationsEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'normal',
|
||||
});
|
||||
} else {
|
||||
// 系统权限被拒绝,不更新用户偏好设置
|
||||
Alert.alert(
|
||||
t('notificationSettings.alerts.permissionDenied.title'),
|
||||
t('notificationSettings.alerts.permissionDenied.message'),
|
||||
[
|
||||
{ text: t('notificationSettings.alerts.permissionDenied.cancel'), style: 'cancel' },
|
||||
{ text: t('notificationSettings.alerts.permissionDenied.goToSettings'), onPress: () => Linking.openSettings() }
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enable push notifications:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.message'));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// 关闭推送,保存用户偏好设置
|
||||
await setNotificationEnabled(false);
|
||||
setNotificationEnabledState(false);
|
||||
// 关闭总开关时,也关闭所有提醒
|
||||
await setMedicationReminderEnabled(false);
|
||||
setMedicationReminderEnabledState(false);
|
||||
await setNutritionReminderEnabled(false);
|
||||
setNutritionReminderEnabledState(false);
|
||||
await setMoodReminderEnabled(false);
|
||||
setMoodReminderEnabledState(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to disable push notifications:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理药品通知提醒开关变化
|
||||
const handleMedicationReminderToggle = async (value: boolean) => {
|
||||
try {
|
||||
await setMedicationReminderEnabled(value);
|
||||
setMedicationReminderEnabledState(value);
|
||||
|
||||
if (value) {
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.medicationReminderEnabled.title'),
|
||||
body: t('notificationSettings.alerts.medicationReminderEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set medication reminder:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.medicationReminderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理营养通知提醒开关变化
|
||||
const handleNutritionReminderToggle = async (value: boolean) => {
|
||||
try {
|
||||
await setNutritionReminderEnabled(value);
|
||||
setNutritionReminderEnabledState(value);
|
||||
|
||||
if (value) {
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.nutritionReminderEnabled.title'),
|
||||
body: t('notificationSettings.alerts.nutritionReminderEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set nutrition reminder:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.nutritionReminderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理心情通知提醒开关变化
|
||||
const handleMoodReminderToggle = async (value: boolean) => {
|
||||
try {
|
||||
await setMoodReminderEnabled(value);
|
||||
setMoodReminderEnabledState(value);
|
||||
|
||||
if (value) {
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.moodReminderEnabled.title'),
|
||||
body: t('notificationSettings.alerts.moodReminderEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set mood reminder:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.moodReminderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染设置项
|
||||
const renderSettingItem = (
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
title: string,
|
||||
description: string,
|
||||
value: boolean,
|
||||
onValueChange: (value: boolean) => void,
|
||||
disabled: boolean = false,
|
||||
showSeparator: boolean = true
|
||||
) => (
|
||||
<View>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.itemInfo}>
|
||||
<View style={[styles.iconContainer, disabled && styles.iconContainerDisabled]}>
|
||||
<Ionicons name={icon} size={24} color={disabled ? '#C7C7CC' : '#9370DB'} />
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.itemTitle, disabled && styles.itemTitleDisabled]}>{title}</Text>
|
||||
<Text style={styles.itemDescription} numberOfLines={2}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
/>
|
||||
</View>
|
||||
{showSeparator && (
|
||||
<View style={styles.separatorContainer}>
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<LinearGradient
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
/>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
|
||||
<LinearGradient
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title={t('notificationSettings.title')}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingTop: safeAreaTop }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 顶部说明卡片 */}
|
||||
<View style={styles.headerSection}>
|
||||
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
|
||||
<View style={styles.descriptionCard}>
|
||||
<View style={styles.hintRow}>
|
||||
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
|
||||
<Text style={styles.descriptionText}>
|
||||
{t('notificationSettings.description.text')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 设置项列表 */}
|
||||
<View style={styles.sectionContainer}>
|
||||
{renderSettingItem(
|
||||
'notifications-outline',
|
||||
t('notificationSettings.items.pushNotifications.title'),
|
||||
t('notificationSettings.items.pushNotifications.description'),
|
||||
notificationEnabled,
|
||||
handleNotificationToggle,
|
||||
false,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'medkit-outline',
|
||||
t('notificationSettings.items.medicationReminder.title'),
|
||||
t('notificationSettings.items.medicationReminder.description'),
|
||||
medicationReminderEnabled,
|
||||
handleMedicationReminderToggle,
|
||||
!notificationEnabled,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'restaurant-outline',
|
||||
t('notificationSettings.items.nutritionReminder.title'),
|
||||
t('notificationSettings.items.nutritionReminder.description'),
|
||||
nutritionReminderEnabled,
|
||||
handleNutritionReminderToggle,
|
||||
!notificationEnabled,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'happy-outline',
|
||||
t('notificationSettings.items.moodReminder.title'),
|
||||
t('notificationSettings.items.moodReminder.description'),
|
||||
moodReminderEnabled,
|
||||
handleMoodReminderToggle,
|
||||
!notificationEnabled,
|
||||
false
|
||||
)}
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '60%',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
headerSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6C757D',
|
||||
marginBottom: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
descriptionCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(147, 112, 219, 0.1)',
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
descriptionText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: '#2C3E50',
|
||||
lineHeight: 18,
|
||||
},
|
||||
sectionContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
marginBottom: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(147, 112, 219, 0.05)',
|
||||
borderRadius: 12,
|
||||
},
|
||||
iconContainerDisabled: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemTitleDisabled: {
|
||||
color: '#999',
|
||||
},
|
||||
itemDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6C757D',
|
||||
lineHeight: 16,
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||
},
|
||||
separatorContainer: {
|
||||
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
|
||||
paddingRight: 16,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#F0F0F0',
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||
@@ -37,10 +39,13 @@ import {
|
||||
type ViewMode = 'daily' | 'all';
|
||||
|
||||
export default function NutritionRecordsScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -88,7 +93,8 @@ export default function NutritionRecordsScreen() {
|
||||
// 页面聚焦时自动刷新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('营养记录页面聚焦,刷新数据...');
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
if (viewMode === 'daily') {
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
@@ -425,6 +431,10 @@ export default function NutritionRecordsScreen() {
|
||||
right={renderRightButton()}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}}>
|
||||
|
||||
{/* {renderViewModeToggle()} */}
|
||||
{renderDateSelector()}
|
||||
|
||||
@@ -466,6 +476,8 @@ export default function NutritionRecordsScreen() {
|
||||
/>
|
||||
)}
|
||||
|
||||
</View>
|
||||
|
||||
{/* 食物添加悬浮窗 */}
|
||||
<FloatingFoodOverlay
|
||||
visible={showFoodOverlay}
|
||||
|
||||
293
app/onboarding.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
FlatList,
|
||||
Image,
|
||||
ImageSourcePropType,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { setOnboardingCompleted } from '@/store/userSlice';
|
||||
|
||||
type OnboardingSlide = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image: ImageSourcePropType;
|
||||
};
|
||||
|
||||
const SLIDES: OnboardingSlide[] = [
|
||||
{
|
||||
key: 'statistics',
|
||||
title: '全方位健康数据追踪',
|
||||
description: '实时监测步数、心率、睡眠等多维度健康指标,助你全面了解身体状况。',
|
||||
image: require('@/assets/images/onboarding/statistic.png'),
|
||||
},
|
||||
{
|
||||
key: 'insights',
|
||||
title: '科学轻断食计划',
|
||||
description: '个性化断食方案,智能提醒与进度追踪,助你改善代谢,科学控脂。',
|
||||
image: require('@/assets/images/onboarding/fasting.jpg'),
|
||||
},
|
||||
{
|
||||
key: 'medication',
|
||||
title: '智能用药管理',
|
||||
description: '个性化用药提醒,智能追踪服药记录,确保按时按量,守护您的健康安全。',
|
||||
image: require('@/assets/images/onboarding/medicine.jpg'),
|
||||
},
|
||||
{
|
||||
key: 'support',
|
||||
title: '健康挑战赛',
|
||||
description: '参与精选健康挑战,与好友一起打卡,保持每日运动动力。',
|
||||
image: require('@/assets/images/onboarding/challange.jpg'),
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { width } = useWindowDimensions();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const listRef = useRef<FlatList<OnboardingSlide>>(null);
|
||||
const indicatorAnim = useRef(SLIDES.map((_, index) => new Animated.Value(index === 0 ? 1 : 0))).current;
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
useEffect(() => {
|
||||
indicatorAnim.forEach((anim, index) => {
|
||||
Animated.timing(anim, {
|
||||
toValue: index === currentIndex ? 1 : 0,
|
||||
duration: 250,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
});
|
||||
}, [currentIndex, indicatorAnim]);
|
||||
|
||||
const updateIndexFromScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const offsetX = event.nativeEvent.contentOffset.x;
|
||||
const nextIndex = Math.round(offsetX / width);
|
||||
if (!Number.isNaN(nextIndex) && nextIndex !== currentIndex) {
|
||||
setCurrentIndex(nextIndex);
|
||||
}
|
||||
},
|
||||
[currentIndex, width],
|
||||
);
|
||||
|
||||
const completeOnboarding = useCallback(async () => {
|
||||
// 通过 Redux 更新 onboarding 状态(会自动保存到 AsyncStorage)
|
||||
await dispatch(setOnboardingCompleted());
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
}, [dispatch, router]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentIndex < SLIDES.length - 1) {
|
||||
const nextIndex = currentIndex + 1;
|
||||
listRef.current?.scrollToOffset({ offset: nextIndex * width, animated: true });
|
||||
setCurrentIndex(nextIndex);
|
||||
return;
|
||||
}
|
||||
completeOnboarding();
|
||||
}, [completeOnboarding, currentIndex, width]);
|
||||
|
||||
const renderSlide = useCallback(
|
||||
({ item }: { item: OnboardingSlide }) => (
|
||||
<View style={[styles.slide, { width }]}>
|
||||
<Image source={item.image} style={styles.slideImage} />
|
||||
</View>
|
||||
),
|
||||
[width],
|
||||
);
|
||||
|
||||
const currentSlide = SLIDES[currentIndex];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<StatusBar style="dark" />
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={handleSkip} hitSlop={12}>
|
||||
<Text style={styles.skipText}>跳过</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.carouselContainer}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={SLIDES}
|
||||
horizontal
|
||||
keyExtractor={(item) => item.key}
|
||||
pagingEnabled
|
||||
decelerationRate="fast"
|
||||
bounces={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
renderItem={renderSlide}
|
||||
onMomentumScrollEnd={updateIndexFromScroll}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<View style={styles.indicatorContainer}>
|
||||
{SLIDES.map((slide, index) => {
|
||||
const animatedStyle = {
|
||||
width: indicatorAnim[index].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [8, 24],
|
||||
}),
|
||||
backgroundColor: indicatorAnim[index].interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['#D8D8D8', '#0066FF'],
|
||||
}),
|
||||
};
|
||||
return <Animated.View key={slide.key} style={[styles.indicatorDot, animatedStyle]} />;
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={styles.title}>{currentSlide.title}</Text>
|
||||
<Text style={styles.description}>{currentSlide.description}</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.primaryButtonWrapper} onPress={handleNext} activeOpacity={0.9}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.primaryButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(122, 90, 248, 0.25)"
|
||||
isInteractive
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[palette.purple[600], palette.purple[400]]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.primaryButtonGradient}
|
||||
/>
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{currentIndex === SLIDES.length - 1 ? '开始使用' : '下一步'}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={[palette.purple[600], palette.purple[400]]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={[styles.primaryButtonGlass, styles.primaryButtonFallback]}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>{currentIndex === SLIDES.length - 1 ? '开始使用' : '下一步'}</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 12,
|
||||
},
|
||||
skipText: {
|
||||
fontSize: 16,
|
||||
color: '#666C7A',
|
||||
fontWeight: '500',
|
||||
},
|
||||
carouselContainer: {
|
||||
flex: 1,
|
||||
marginTop: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
slide: {
|
||||
height: 'auto',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
slideImage: {
|
||||
width: '85%',
|
||||
height: '100%',
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
indicatorContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginVertical: 24,
|
||||
gap: 10,
|
||||
},
|
||||
indicatorDot: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
color: '#222532',
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#5C6373',
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
primaryButtonWrapper: {
|
||||
marginTop: 16,
|
||||
},
|
||||
primaryButtonGlass: {
|
||||
borderRadius: 24,
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: palette.purple[600],
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
primaryButtonFallback: {
|
||||
borderRadius: 24,
|
||||
},
|
||||
primaryButtonGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -3,10 +3,13 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { syncServerToHealthKit } from '@/services/healthKitSync';
|
||||
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchMaximumHeartRate } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -17,19 +20,19 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
interface UserProfile {
|
||||
@@ -47,6 +50,8 @@ interface UserProfile {
|
||||
const STORAGE_KEY = '@user_profile';
|
||||
|
||||
export default function EditProfileScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -78,7 +83,8 @@ export default function EditProfileScreen() {
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [tempValue, setTempValue] = useState<string>('');
|
||||
|
||||
// 输入框字符串
|
||||
// 键盘高度状态
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
|
||||
// 从本地存储加载(身高/体重等本地字段)
|
||||
const loadLocalProfile = async () => {
|
||||
@@ -125,6 +131,34 @@ export default function EditProfileScreen() {
|
||||
loadLocalProfile();
|
||||
}, []);
|
||||
|
||||
// 键盘事件监听器 - 只在名称和体重输入框显示时监听
|
||||
useEffect(() => {
|
||||
// 只有在编辑名称或体重字段时才需要监听键盘(这两个字段使用 TextInput)
|
||||
const needsKeyboardHandling = editingField === 'name' || editingField === 'weight';
|
||||
|
||||
if (!needsKeyboardHandling) {
|
||||
setKeyboardHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const handleShow = (event: any) => {
|
||||
const height = event?.endCoordinates?.height ?? 0;
|
||||
setKeyboardHeight(height);
|
||||
};
|
||||
const handleHide = () => setKeyboardHeight(0);
|
||||
|
||||
const showSub = Keyboard.addListener(showEvent, handleShow);
|
||||
const hideSub = Keyboard.addListener(hideEvent, handleHide);
|
||||
|
||||
return () => {
|
||||
showSub.remove();
|
||||
hideSub.remove();
|
||||
};
|
||||
}, [editingField]);
|
||||
|
||||
// 获取最大心率数据
|
||||
useEffect(() => {
|
||||
const loadMaximumHeartRate = async () => {
|
||||
@@ -189,7 +223,7 @@ export default function EditProfileScreen() {
|
||||
const handleSaveWithProfile = async (profileData: UserProfile) => {
|
||||
try {
|
||||
if (!userId) {
|
||||
Alert.alert('未登录', '请先登录后再尝试保存');
|
||||
Alert.alert(t('editProfile.alerts.notLoggedIn.title'), t('editProfile.alerts.notLoggedIn.message'));
|
||||
return;
|
||||
}
|
||||
const next: UserProfile = { ...profileData };
|
||||
@@ -210,12 +244,24 @@ export default function EditProfileScreen() {
|
||||
}));
|
||||
// 拉取最新用户信息,刷新全局状态
|
||||
await dispatch(fetchMyProfile() as any);
|
||||
|
||||
// 同步身高、体重到 HealthKit
|
||||
console.log('开始同步个人健康数据到 HealthKit...');
|
||||
const syncSuccess = await syncServerToHealthKit({
|
||||
height: next.height,
|
||||
weight: next.weight,
|
||||
birthDate: next.birthDate ? new Date(next.birthDate).getTime() / 1000 : undefined
|
||||
});
|
||||
|
||||
if (syncSuccess) {
|
||||
console.log('个人健康数据已同步到 HealthKit');
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 接口失败不阻断本地保存
|
||||
console.warn('更新用户信息失败', e?.message || e);
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('保存失败', '请稍后重试');
|
||||
Alert.alert(t('editProfile.alerts.saveFailed.title'), t('editProfile.alerts.saveFailed.message'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -249,7 +295,7 @@ export default function EditProfileScreen() {
|
||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
|
||||
if (!libGranted) {
|
||||
Alert.alert('权限不足', '需要相册权限以选择头像');
|
||||
Alert.alert(t('editProfile.alerts.avatarPermissions.title'), t('editProfile.alerts.avatarPermissions.message'));
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
@@ -275,24 +321,21 @@ export default function EditProfileScreen() {
|
||||
setProfile((p) => ({ ...p, avatarUri: url }));
|
||||
// 保存更新后的 profile
|
||||
await handleSaveWithProfile({ ...profile, avatarUri: url });
|
||||
Alert.alert('成功', '头像更新成功');
|
||||
Alert.alert(t('editProfile.alerts.avatarSuccess.title'), t('editProfile.alerts.avatarSuccess.message'));
|
||||
} catch (e) {
|
||||
console.warn('上传头像失败', e);
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
Alert.alert(t('editProfile.alerts.avatarUploadFailed.title'), t('editProfile.alerts.avatarUploadFailed.message'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('发生错误', '选择头像失败,请重试');
|
||||
Alert.alert(t('editProfile.alerts.avatarError.title'), t('editProfile.alerts.avatarError.message'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||||
<StatusBar barStyle={'dark-content'} />
|
||||
|
||||
{/* HeaderBar 放在 ScrollView 外部,确保全宽显示 */}
|
||||
<View style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
|
||||
<HeaderBar
|
||||
title="编辑资料"
|
||||
title={t('editProfile.title')}
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
transparent={true}
|
||||
@@ -300,7 +343,7 @@ export default function EditProfileScreen() {
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={{ flex: 1 }}>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 40, paddingTop: safeAreaTop }} style={{ paddingHorizontal: 20 }} showsVerticalScrollIndicator={false}>
|
||||
|
||||
{/* 头像(带相机蒙层,点击从相册选择) */}
|
||||
<View style={{ alignItems: 'center', marginTop: 4, marginBottom: 32 }}>
|
||||
@@ -324,8 +367,8 @@ export default function EditProfileScreen() {
|
||||
{/* 姓名 */}
|
||||
<ProfileCard
|
||||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-name.png'
|
||||
title="昵称"
|
||||
value={profile.name || '今晚要吃肉'}
|
||||
title={t('editProfile.fields.name')}
|
||||
value={profile.name || t('editProfile.defaultValues.name')}
|
||||
onPress={() => {
|
||||
setTempValue(profile.name || '');
|
||||
setEditingField('name');
|
||||
@@ -337,8 +380,8 @@ export default function EditProfileScreen() {
|
||||
icon="body"
|
||||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-sex.png"
|
||||
iconColor="#FF6B9D"
|
||||
title="性别"
|
||||
value={profile.gender === 'male' ? '男' : profile.gender === 'female' ? '女' : '未设置'}
|
||||
title={t('editProfile.fields.gender')}
|
||||
value={profile.gender === 'male' ? t('editProfile.gender.male') : profile.gender === 'female' ? t('editProfile.gender.female') : t('editProfile.gender.notSet')}
|
||||
onPress={() => {
|
||||
setEditingField('gender');
|
||||
}}
|
||||
@@ -347,10 +390,10 @@ export default function EditProfileScreen() {
|
||||
{/* 身高 */}
|
||||
<ProfileCard
|
||||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-height.png"
|
||||
title="身高"
|
||||
value={profile.height ? `${Math.round(profile.height)}厘米` : '170厘米'}
|
||||
title={t('editProfile.fields.height')}
|
||||
value={profile.height ? `${Math.round(profile.height)}${t('editProfile.height.unit')}` : t('editProfile.height.placeholder')}
|
||||
onPress={() => {
|
||||
setTempValue(profile.height ? String(Math.round(profile.height)) : '170');
|
||||
setTempValue(profile.height ? String(Math.round(profile.height)) : String(t('editProfile.defaultValues.height')));
|
||||
setEditingField('height');
|
||||
}}
|
||||
/>
|
||||
@@ -358,10 +401,10 @@ export default function EditProfileScreen() {
|
||||
{/* 体重 */}
|
||||
<ProfileCard
|
||||
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-weight.png"
|
||||
title="体重"
|
||||
value={profile.weight ? `${round(profile.weight, 1)}公斤` : '55公斤'}
|
||||
title={t('editProfile.fields.weight')}
|
||||
value={profile.weight ? `${round(profile.weight, 1)}${t('editProfile.weight.unit')}` : t('editProfile.weight.placeholder')}
|
||||
onPress={() => {
|
||||
setTempValue(profile.weight ? String(round(profile.weight, 1)) : '55');
|
||||
setTempValue(profile.weight ? String(round(profile.weight, 1)) : String(t('editProfile.defaultValues.weight')));
|
||||
setEditingField('weight');
|
||||
}}
|
||||
/>
|
||||
@@ -369,14 +412,14 @@ export default function EditProfileScreen() {
|
||||
{/* 活动水平 */}
|
||||
<ProfileCard
|
||||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-activity.png'
|
||||
title="活动水平"
|
||||
title={t('editProfile.fields.activityLevel')}
|
||||
value={(() => {
|
||||
switch (profile.activityLevel) {
|
||||
case 1: return '久坐';
|
||||
case 2: return '轻度活跃';
|
||||
case 3: return '中度活跃';
|
||||
case 4: return '非常活跃';
|
||||
default: return '久坐';
|
||||
case 1: return t('editProfile.activityLevels.1');
|
||||
case 2: return t('editProfile.activityLevels.2');
|
||||
case 3: return t('editProfile.activityLevels.3');
|
||||
case 4: return t('editProfile.activityLevels.4');
|
||||
default: return t('editProfile.activityLevels.1');
|
||||
}
|
||||
})()}
|
||||
onPress={() => {
|
||||
@@ -387,15 +430,20 @@ export default function EditProfileScreen() {
|
||||
{/* 出生日期 */}
|
||||
<ProfileCard
|
||||
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-birth.png'
|
||||
title="出生日期"
|
||||
title={t('editProfile.fields.birthDate')}
|
||||
value={profile.birthDate ? (() => {
|
||||
try {
|
||||
const d = new Date(profile.birthDate);
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
} catch {
|
||||
return '1995年1月1日';
|
||||
if (t('editProfile.birthDate.format').includes('{{year}}年')) {
|
||||
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() });
|
||||
} else {
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: monthNames[d.getMonth()], day: d.getDate() });
|
||||
}
|
||||
})() : '1995年1月1日'}
|
||||
} catch {
|
||||
return t('editProfile.birthDate.placeholder');
|
||||
}
|
||||
})() : t('editProfile.birthDate.placeholder')}
|
||||
onPress={() => {
|
||||
openDatePicker();
|
||||
}}
|
||||
@@ -405,11 +453,11 @@ export default function EditProfileScreen() {
|
||||
<ProfileCard
|
||||
icon="heart"
|
||||
iconColor="#FF6B9D"
|
||||
title="最大心率"
|
||||
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
|
||||
title={t('editProfile.fields.maxHeartRate')}
|
||||
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}${t('editProfile.maxHeartRate.unit')}` : t('editProfile.maxHeartRate.notAvailable')}
|
||||
onPress={() => {
|
||||
// 最大心率不可编辑,只显示
|
||||
Alert.alert('提示', '最大心率数据从健康应用自动获取');
|
||||
Alert.alert(t('editProfile.maxHeartRate.alert.title'), t('editProfile.maxHeartRate.alert.message'));
|
||||
}}
|
||||
disabled={true}
|
||||
hideArrow={true}
|
||||
@@ -422,6 +470,7 @@ export default function EditProfileScreen() {
|
||||
field={editingField}
|
||||
value={tempValue}
|
||||
profile={profile}
|
||||
keyboardHeight={keyboardHeight}
|
||||
onClose={() => {
|
||||
setEditingField(null);
|
||||
setTempValue('');
|
||||
@@ -435,6 +484,7 @@ export default function EditProfileScreen() {
|
||||
} else if (field === 'gender') {
|
||||
updatedProfile.gender = value as 'male' | 'female';
|
||||
setProfile(p => ({ ...p, gender: value as 'male' | 'female' }));
|
||||
|
||||
} else if (field === 'height') {
|
||||
updatedProfile.height = parseFloat(value) || undefined;
|
||||
setProfile(p => ({ ...p, height: parseFloat(value) || undefined }));
|
||||
@@ -458,8 +508,8 @@ export default function EditProfileScreen() {
|
||||
colors={colors}
|
||||
textColor={textColor}
|
||||
placeholderColor={placeholderColor}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* 出生日期选择器弹窗 */}
|
||||
<Modal
|
||||
visible={datePickerVisible}
|
||||
@@ -491,12 +541,12 @@ export default function EditProfileScreen() {
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('editProfile.modals.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('editProfile.modals.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
@@ -504,7 +554,7 @@ export default function EditProfileScreen() {
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -539,17 +589,20 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled
|
||||
);
|
||||
}
|
||||
|
||||
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor }: {
|
||||
function EditModal({ visible, field, value, profile, keyboardHeight, onClose, onSave, colors, textColor, placeholderColor, t }: {
|
||||
visible: boolean;
|
||||
field: string | null;
|
||||
value: string;
|
||||
profile: UserProfile;
|
||||
keyboardHeight: number;
|
||||
onClose: () => void;
|
||||
onSave: (field: string, value: string) => void;
|
||||
colors: any;
|
||||
textColor: string;
|
||||
placeholderColor: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
|
||||
const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1);
|
||||
@@ -566,10 +619,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
case 'name':
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.modalTitle}>昵称</Text>
|
||||
<Text style={styles.modalTitle}>{t('editProfile.fields.name')}</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||||
placeholder="输入昵称"
|
||||
placeholder={t('editProfile.modals.input.namePlaceholder')}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
@@ -580,21 +633,21 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
case 'gender':
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.modalTitle}>性别</Text>
|
||||
<Text style={styles.modalTitle}>{t('editProfile.fields.gender')}</Text>
|
||||
<View style={styles.genderSelector}>
|
||||
<TouchableOpacity
|
||||
style={[styles.genderOption, selectedGender === 'female' && { backgroundColor: colors.primary + '20' }]}
|
||||
onPress={() => setSelectedGender('female')}
|
||||
>
|
||||
<Text style={[styles.genderEmoji, selectedGender === 'female' && { color: colors.primary }]}>♀</Text>
|
||||
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}>女性</Text>
|
||||
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}>{t('editProfile.modals.female')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.genderOption, selectedGender === 'male' && { backgroundColor: colors.primary + '20' }]}
|
||||
onPress={() => setSelectedGender('male')}
|
||||
>
|
||||
<Text style={[styles.genderEmoji, selectedGender === 'male' && { color: colors.primary }]}>♂</Text>
|
||||
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}>男性</Text>
|
||||
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}>{t('editProfile.modals.male')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -602,7 +655,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
case 'height':
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.modalTitle}>身高</Text>
|
||||
<Text style={styles.modalTitle}>{t('editProfile.fields.height')}</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={inputValue}
|
||||
@@ -610,7 +663,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
style={styles.picker}
|
||||
>
|
||||
{Array.from({ length: 101 }, (_, i) => 120 + i).map(height => (
|
||||
<Picker.Item key={height} label={`${height}厘米`} value={String(height)} />
|
||||
<Picker.Item key={height} label={`${height}${t('editProfile.height.unit')}`} value={String(height)} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
@@ -619,29 +672,29 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
case 'weight':
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.modalTitle}>体重</Text>
|
||||
<Text style={styles.modalTitle}>{t('editProfile.fields.weight')}</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
|
||||
placeholder="输入体重"
|
||||
placeholder={t('editProfile.modals.input.weightPlaceholder')}
|
||||
placeholderTextColor={placeholderColor}
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
keyboardType="numeric"
|
||||
autoFocus
|
||||
/>
|
||||
<Text style={styles.unitText}>公斤 (kg)</Text>
|
||||
<Text style={styles.unitText}>{t('editProfile.modals.input.weightUnit')}</Text>
|
||||
</View>
|
||||
);
|
||||
case 'activity':
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.modalTitle}>活动水平</Text>
|
||||
<Text style={styles.modalTitle}>{t('editProfile.fields.activityLevel')}</Text>
|
||||
<View style={styles.activitySelector}>
|
||||
{[
|
||||
{ key: 1, label: '久坐', desc: '很少运动' },
|
||||
{ key: 2, label: '轻度活跃', desc: '每周1-3次运动' },
|
||||
{ key: 3, label: '中度活跃', desc: '每周3-5次运动' },
|
||||
{ key: 4, label: '非常活跃', desc: '每周6-7次运动' },
|
||||
{ key: 1, label: t('editProfile.activityLevels.1'), desc: t('editProfile.activityLevels.descriptions.1') },
|
||||
{ key: 2, label: t('editProfile.activityLevels.2'), desc: t('editProfile.activityLevels.descriptions.2') },
|
||||
{ key: 3, label: t('editProfile.activityLevels.3'), desc: t('editProfile.activityLevels.descriptions.3') },
|
||||
{ key: 4, label: t('editProfile.activityLevels.4'), desc: t('editProfile.activityLevels.descriptions.4') },
|
||||
].map(item => (
|
||||
<TouchableOpacity
|
||||
key={item.key}
|
||||
@@ -666,12 +719,15 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={onClose} />
|
||||
<View style={styles.editModalSheet}>
|
||||
<View style={[
|
||||
styles.editModalSheet,
|
||||
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 }
|
||||
]}>
|
||||
<View style={styles.modalHandle} />
|
||||
{renderContent()}
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.modalCancelBtn}>
|
||||
<Text style={styles.modalCancelText}>取消</Text>
|
||||
<Text style={styles.modalCancelText}>{t('editProfile.modals.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
@@ -685,7 +741,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
}}
|
||||
style={[styles.modalSaveBtn, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}>保存</Text>
|
||||
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}>{t('editProfile.modals.save')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -6,17 +6,17 @@ import * as Haptics from 'expo-haptics';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { ProgressBar } from '@/components/ProgressBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { updateUser as updateUserApi } from '@/services/users';
|
||||
import { fetchMyProfile } from '@/store/userSlice';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
@@ -43,6 +43,7 @@ function arraysEqualUnordered(a?: string[], b?: string[]): boolean {
|
||||
}
|
||||
|
||||
export default function GoalsScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
@@ -135,7 +136,7 @@ export default function GoalsScreen() {
|
||||
lastSentRef.current.calories = calories;
|
||||
(async () => {
|
||||
try {
|
||||
await updateUserApi({ userId, dailyCaloriesGoal: calories });
|
||||
await updateUserApi({ dailyCaloriesGoal: calories });
|
||||
await dispatch(fetchMyProfile() as any);
|
||||
} catch { }
|
||||
})();
|
||||
@@ -148,7 +149,7 @@ export default function GoalsScreen() {
|
||||
lastSentRef.current.steps = steps;
|
||||
(async () => {
|
||||
try {
|
||||
await updateUserApi({ userId, dailyStepsGoal: steps });
|
||||
await updateUserApi({ dailyStepsGoal: steps });
|
||||
await dispatch(fetchMyProfile() as any);
|
||||
} catch { }
|
||||
})();
|
||||
@@ -161,7 +162,7 @@ export default function GoalsScreen() {
|
||||
lastSentRef.current.purposes = [...purposes];
|
||||
(async () => {
|
||||
try {
|
||||
await updateUserApi({ userId, pilatesPurposes: purposes });
|
||||
await updateUserApi({ pilatesPurposes: purposes });
|
||||
await dispatch(fetchMyProfile() as any);
|
||||
} catch { }
|
||||
})();
|
||||
@@ -245,9 +246,9 @@ export default function GoalsScreen() {
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme === 'light' ? '#F5F5F5' : colors.background }]}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<View style={styles.safeArea}>
|
||||
<HeaderBar title="目标管理" onBack={() => router.back()} withSafeTop={false} tone={theme} transparent />
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16) }]}
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: Math.max(20, insets.bottom + 16), paddingTop: safeAreaTop }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<SectionCard title="每日卡路里消耗目标" subtitle="设置你计划每天通过活动消耗的热量">
|
||||
@@ -305,7 +306,7 @@ export default function GoalsScreen() {
|
||||
</SectionCard>
|
||||
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
328
app/push-notification-settings.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { getTokenStatusDescription, isPushNotificationAvailable, usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { TokenStatus } from '@/services/pushNotificationManager';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
export default function PushNotificationSettingsScreen() {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const primaryColor = useThemeColor({}, 'primary');
|
||||
|
||||
const {
|
||||
isInitialized,
|
||||
tokenStatus,
|
||||
isLoading,
|
||||
registerToken,
|
||||
getCurrentToken,
|
||||
updateTokenStatus,
|
||||
clearAllData,
|
||||
} = usePushNotifications();
|
||||
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [currentToken, setCurrentToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
updateTokenStatus();
|
||||
setCurrentToken(getCurrentToken());
|
||||
}, [isInitialized, tokenStatus]);
|
||||
|
||||
const handleRegisterToken = async () => {
|
||||
setIsRegistering(true);
|
||||
try {
|
||||
const success = await registerToken();
|
||||
if (success) {
|
||||
Alert.alert('成功', '设备令牌注册成功');
|
||||
} else {
|
||||
Alert.alert('失败', '设备令牌注册失败,请检查网络连接和权限设置');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册设备令牌失败:', error);
|
||||
Alert.alert('错误', '注册设备令牌时发生错误');
|
||||
} finally {
|
||||
setIsRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearData = () => {
|
||||
Alert.alert(
|
||||
'确认清除',
|
||||
'这将清除所有本地推送通知数据,包括设备令牌。确定要继续吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await clearAllData();
|
||||
Alert.alert('成功', '推送通知数据已清除');
|
||||
} catch (error) {
|
||||
console.error('清除数据失败:', error);
|
||||
Alert.alert('错误', '清除数据时发生错误');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
if (currentToken) {
|
||||
// 这里可以使用 Clipboard API 复制到剪贴板
|
||||
Alert.alert('令牌已复制', '设备令牌已复制到剪贴板(功能待实现)');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (tokenStatus) {
|
||||
case TokenStatus.REGISTERED:
|
||||
return '#4CAF50';
|
||||
case TokenStatus.GRANTED:
|
||||
return '#2196F3';
|
||||
case TokenStatus.DENIED:
|
||||
return '#F44336';
|
||||
case TokenStatus.FAILED:
|
||||
return '#FF9800';
|
||||
default:
|
||||
return '#9E9E9E';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={primaryColor} />
|
||||
<ThemedText style={styles.loadingText}>加载推送通知设置...</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.container, { backgroundColor }]}>
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.title}>推送通知设置</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>
|
||||
管理您的推送通知权限和设备令牌
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { borderColor }]}>
|
||||
<ThemedText style={styles.cardTitle}>状态信息</ThemedText>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<ThemedText style={styles.statusLabel}>初始化状态:</ThemedText>
|
||||
<View style={styles.statusValue}>
|
||||
<Text style={[styles.statusText, { color: isInitialized ? '#4CAF50' : '#F44336' }]}>
|
||||
{isInitialized ? '已初始化' : '未初始化'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<ThemedText style={styles.statusLabel}>令牌状态:</ThemedText>
|
||||
<View style={styles.statusValue}>
|
||||
<Text style={[styles.statusText, { color: getStatusColor() }]}>
|
||||
{getTokenStatusDescription(tokenStatus)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusRow}>
|
||||
<ThemedText style={styles.statusLabel}>推送可用:</ThemedText>
|
||||
<View style={styles.statusValue}>
|
||||
<Text style={[styles.statusText, { color: isPushNotificationAvailable(tokenStatus) ? '#4CAF50' : '#F44336' }]}>
|
||||
{isPushNotificationAvailable(tokenStatus) ? '是' : '否'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{currentToken && (
|
||||
<View style={[styles.card, { borderColor }]}>
|
||||
<ThemedText style={styles.cardTitle}>设备令牌</ThemedText>
|
||||
<View style={styles.tokenContainer}>
|
||||
<Text style={[styles.tokenText, { color: textColor }]}>
|
||||
{currentToken.substring(0, 20)}...{currentToken.substring(currentToken.length - 10)}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.copyButton, { borderColor }]}
|
||||
onPress={copyTokenToClipboard}
|
||||
>
|
||||
<Text style={[styles.copyButtonText, { color: primaryColor }]}>复制</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.card, { borderColor }]}>
|
||||
<ThemedText style={styles.cardTitle}>操作</ThemedText>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: primaryColor }]}
|
||||
onPress={handleRegisterToken}
|
||||
disabled={isRegistering}
|
||||
>
|
||||
{isRegistering ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text style={styles.actionButtonText}>注册设备令牌</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.secondaryButton, { borderColor }]}
|
||||
onPress={() => updateTokenStatus()}
|
||||
>
|
||||
<Text style={[styles.secondaryButtonText, { color: primaryColor }]}>刷新状态</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.dangerButton]}
|
||||
onPress={handleClearData}
|
||||
>
|
||||
<Text style={styles.dangerButtonText}>清除所有数据</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { borderColor }]}>
|
||||
<ThemedText style={styles.cardTitle}>说明</ThemedText>
|
||||
<ThemedText style={styles.description}>
|
||||
• 推送通知需要在真实iOS设备上使用{'\n'}
|
||||
• 设备令牌用于接收远程推送通知{'\n'}
|
||||
• 如果令牌状态显示"权限被拒绝",请在系统设置中开启通知权限{'\n'}
|
||||
• 清除数据后需要重新初始化推送通知
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
statusValue: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tokenContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
tokenText: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
copyButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
},
|
||||
copyButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
actionButton: {
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
actionButtonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: '#F44336',
|
||||
},
|
||||
dangerButtonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
266
app/settings/tab-bar-config.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
resetToDefault,
|
||||
selectTabBarConfigs,
|
||||
toggleTabEnabled,
|
||||
type TabConfig,
|
||||
} from '@/store/tabBarConfigSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { palette } from '@/constants/Colors';
|
||||
|
||||
export default function TabBarConfigScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const safeAreaTop = useSafeAreaTop(60);
|
||||
const configs = useAppSelector(selectTabBarConfigs);
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 处理开关切换
|
||||
const handleToggle = useCallback(
|
||||
(tabId: string) => {
|
||||
dispatch(toggleTabEnabled(tabId));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// 恢复默认设置
|
||||
const handleReset = useCallback(() => {
|
||||
Alert.alert(
|
||||
t('personal.tabBarConfig.resetConfirm.title'),
|
||||
t('personal.tabBarConfig.resetConfirm.message'),
|
||||
[
|
||||
{
|
||||
text: t('personal.tabBarConfig.resetConfirm.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: t('personal.tabBarConfig.resetConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
dispatch(resetToDefault());
|
||||
Alert.alert('', t('personal.tabBarConfig.resetSuccess'));
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [dispatch, t]);
|
||||
|
||||
// 渲染单个 Tab 行
|
||||
const renderTabRow = useCallback(
|
||||
(item: TabConfig, index: number, total: number) => {
|
||||
return (
|
||||
<View key={item.id}>
|
||||
<View style={styles.tabItem}>
|
||||
{/* Tab 图标和名称 */}
|
||||
<View style={styles.tabInfo}>
|
||||
<View style={styles.iconContainer}>
|
||||
<IconSymbol name={item.icon as any} size={24} color="#9370DB" />
|
||||
</View>
|
||||
<View style={styles.tabTextContainer}>
|
||||
<Text style={styles.tabTitle}>{t(item.titleKey)}</Text>
|
||||
{!item.canBeDisabled && (
|
||||
<Text style={styles.tabSubtitle}>
|
||||
{t('personal.tabBarConfig.cannotDisable')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开关 */}
|
||||
<Switch
|
||||
value={item.enabled}
|
||||
onValueChange={() => handleToggle(item.id)}
|
||||
disabled={!item.canBeDisabled}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 分割线 - 最后一项不显示 */}
|
||||
{index < total - 1 && (
|
||||
<View style={styles.separatorContainer}>
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleToggle, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
/>
|
||||
|
||||
{/* 顶部导航栏 */}
|
||||
<HeaderBar
|
||||
title={t('personal.tabBarConfig.title')}
|
||||
onBack={() => router.back()}
|
||||
right={
|
||||
<TouchableOpacity onPress={handleReset} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Text style={styles.headerRightButton}>
|
||||
{t('personal.tabBarConfig.resetButton')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingTop: safeAreaTop }]} // 增加顶部间距,因为 HeaderBar 现在是 absolute 的
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 说明区域 */}
|
||||
<View style={styles.headerSection}>
|
||||
<Text style={styles.subtitle}>{t('personal.tabBarConfig.subtitle')}</Text>
|
||||
<View style={styles.descriptionCard}>
|
||||
<View style={styles.hintRow}>
|
||||
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
|
||||
<Text style={styles.descriptionText}>
|
||||
{t('personal.tabBarConfig.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 列表 - 聚合在一个卡片中 */}
|
||||
<View style={styles.sectionContainer}>
|
||||
{configs.map((item, index) => renderTabRow(item, index, configs.length))}
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '60%', // 渐变覆盖上半部分即可
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
headerSection: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6C757D',
|
||||
marginBottom: 12,
|
||||
},
|
||||
descriptionCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(147, 112, 219, 0.1)',
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
descriptionText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: '#2C3E50',
|
||||
lineHeight: 18,
|
||||
},
|
||||
sectionContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
marginBottom: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
separatorContainer: {
|
||||
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
|
||||
paddingRight: 16,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#F0F0F0',
|
||||
},
|
||||
tabInfo: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
tabTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 2,
|
||||
},
|
||||
tabSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#9370DB',
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||
},
|
||||
headerRightButton: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#9370DB', // 使用主色调
|
||||
},
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { SleepStageTimeline } from '@/components/sleep/SleepStageTimeline';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
// SleepGradeCard 组件现在在 InfoModal 组件内部
|
||||
|
||||
@@ -34,6 +35,8 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
// InfoModal 组件现在从独立文件导入
|
||||
|
||||
export default function SleepDetailScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const [sleepData, setSleepData] = useState<CompleteSleepData | null>(null);
|
||||
@@ -123,7 +126,9 @@ export default function SleepDetailScreen() {
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 睡眠得分圆形显示 */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function StepsDetailScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
// 获取路由参数
|
||||
const { date } = useLocalSearchParams<{ date?: string }>();
|
||||
|
||||
@@ -213,7 +216,9 @@ export default function StepsDetailScreen() {
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{}}
|
||||
contentContainerStyle={{
|
||||
paddingTop: safeAreaTop
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 日期选择器 */}
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
export default function TaskDetailScreen() {
|
||||
const { taskId } = useLocalSearchParams<{ taskId: string }>();
|
||||
const router = useRouter();
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const { showConfirm } = useGlobalDialog();
|
||||
|
||||
// 从Redux中获取任务数据
|
||||
const { tasks, tasksLoading } = useAppSelector(state => state.tasks);
|
||||
const task = tasks.find(t => t.id === taskId) || null;
|
||||
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'in_progress':
|
||||
return '进行中';
|
||||
case 'overdue':
|
||||
return '已过期';
|
||||
case 'skipped':
|
||||
return '已跳过';
|
||||
default:
|
||||
return '待开始';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '#10B981';
|
||||
case 'in_progress':
|
||||
return '#7A5AF8';
|
||||
case 'overdue':
|
||||
return '#EF4444';
|
||||
case 'skipped':
|
||||
return '#6B7280';
|
||||
default:
|
||||
return '#6B7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyText = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'very_easy':
|
||||
return '非常简单 (少于一天)';
|
||||
case 'easy':
|
||||
return '简单 (1-2天)';
|
||||
case 'medium':
|
||||
return '中等 (3-5天)';
|
||||
case 'hard':
|
||||
return '困难 (1-2周)';
|
||||
case 'very_hard':
|
||||
return '非常困难 (2周以上)';
|
||||
default:
|
||||
return '非常简单 (少于一天)';
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyColor = (difficulty: string) => {
|
||||
switch (difficulty) {
|
||||
case 'very_easy':
|
||||
return '#10B981';
|
||||
case 'easy':
|
||||
return '#34D399';
|
||||
case 'medium':
|
||||
return '#F59E0B';
|
||||
case 'hard':
|
||||
return '#F97316';
|
||||
case 'very_hard':
|
||||
return '#EF4444';
|
||||
default:
|
||||
return '#10B981';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
};
|
||||
return `创建于 ${date.toLocaleDateString('zh-CN', options)}`;
|
||||
};
|
||||
|
||||
const handleCompleteTask = async () => {
|
||||
if (!task || task.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(completeTask({
|
||||
taskId: task.id,
|
||||
completionData: {
|
||||
count: 1,
|
||||
notes: '通过任务详情页面完成'
|
||||
}
|
||||
})).unwrap();
|
||||
|
||||
// 检查任务是否真正完成(当前完成次数是否达到目标次数)
|
||||
const updatedTask = tasks.find(t => t.id === task.id);
|
||||
if (updatedTask && updatedTask.currentCount >= updatedTask.targetCount) {
|
||||
Alert.alert('成功', '任务已完成!');
|
||||
router.back();
|
||||
} else {
|
||||
Alert.alert('成功', '任务进度已更新!');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '完成任务失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipTask = async () => {
|
||||
if (!task || task.status === 'completed' || task.status === 'skipped') {
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirm(
|
||||
{
|
||||
title: '确认跳过任务',
|
||||
message: `确定要跳过任务"${task.title}"吗?\n\n跳过后的任务将不会显示在任务列表中,且无法恢复。`,
|
||||
confirmText: '跳过',
|
||||
cancelText: '取消',
|
||||
destructive: true,
|
||||
icon: 'warning',
|
||||
iconColor: '#F59E0B',
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
await dispatch(skipTask({
|
||||
taskId: task.id,
|
||||
skipData: {
|
||||
reason: '用户主动跳过'
|
||||
}
|
||||
})).unwrap();
|
||||
|
||||
Alert.alert('成功', '任务已跳过!');
|
||||
router.back();
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '跳过任务失败,请重试');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSendComment = () => {
|
||||
if (comment.trim()) {
|
||||
// 这里应该调用API发送评论
|
||||
console.log('发送评论:', comment);
|
||||
setComment('');
|
||||
Alert.alert('成功', '评论已发送!');
|
||||
}
|
||||
};
|
||||
|
||||
if (tasksLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar
|
||||
title="任务详情"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={[styles.loadingText, { color: colorTokens.text }]}>加载中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar
|
||||
title="任务详情"
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={[styles.errorText, { color: colorTokens.text }]}>任务不存在</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
{/* 使用HeaderBar组件 */}
|
||||
<HeaderBar
|
||||
title="任务详情"
|
||||
onBack={() => router.back()}
|
||||
right={
|
||||
task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount ? (
|
||||
<TouchableOpacity onPress={handleCompleteTask} style={styles.completeButton}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/iconTaskHeader.png')}
|
||||
style={styles.taskIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
|
||||
{/* 任务标题和创建时间 */}
|
||||
<View style={styles.titleSection}>
|
||||
<Text style={[styles.taskTitle, { color: colorTokens.text }]}>{task.title}</Text>
|
||||
<Text style={[styles.createdDate, { color: colorTokens.textSecondary }]}>
|
||||
{formatDate(task.startDate)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<View style={styles.statusContainer}>
|
||||
<View style={[styles.statusTag, { backgroundColor: getStatusColor(task.status) }]}>
|
||||
<Text style={styles.statusTagText}>{getStatusText(task.status)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 描述区域 */}
|
||||
<View style={styles.descriptionSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>描述</Text>
|
||||
<Text style={[styles.descriptionText, { color: colorTokens.textSecondary }]}>
|
||||
{task.description || '暂无描述'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 优先级和难度 */}
|
||||
<View style={styles.infoSection}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={[styles.infoLabel, { color: colorTokens.text }]}>优先级</Text>
|
||||
<View style={styles.priorityTag}>
|
||||
<MaterialIcons name="flag" size={16} color="#FFFFFF" />
|
||||
<Text style={styles.priorityTagText}>高</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={[styles.infoLabel, { color: colorTokens.text }]}>难度</Text>
|
||||
<View style={[styles.difficultyTag, { backgroundColor: getDifficultyColor('very_easy') }]}>
|
||||
<MaterialIcons name="sentiment-satisfied" size={16} color="#FFFFFF" />
|
||||
<Text style={styles.difficultyTagText}>非常简单 (少于一天)</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 任务进度信息 */}
|
||||
<View style={styles.progressSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>进度</Text>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{
|
||||
width: task.progressPercentage > 0 ? `${Math.min(task.progressPercentage, 100)}%` : '2%',
|
||||
backgroundColor: task.progressPercentage >= 100
|
||||
? '#10B981'
|
||||
: task.progressPercentage >= 50
|
||||
? '#F59E0B'
|
||||
: task.progressPercentage > 0
|
||||
? colorTokens.primary
|
||||
: '#E5E7EB',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{task.progressPercentage > 0 && task.progressPercentage < 100 && (
|
||||
<View style={styles.progressGlow} />
|
||||
)}
|
||||
{/* 进度文本 */}
|
||||
<View style={styles.progressTextContainer}>
|
||||
<Text style={styles.progressText}>{task.currentCount}/{task.targetCount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 进度详细信息 */}
|
||||
<View style={styles.progressInfo}>
|
||||
<View style={styles.progressItem}>
|
||||
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}>目标次数</Text>
|
||||
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.targetCount}</Text>
|
||||
</View>
|
||||
<View style={styles.progressItem}>
|
||||
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}>已完成</Text>
|
||||
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.currentCount}</Text>
|
||||
</View>
|
||||
<View style={styles.progressItem}>
|
||||
<Text style={[styles.progressLabel, { color: colorTokens.textSecondary }]}>剩余天数</Text>
|
||||
<Text style={[styles.progressValue, { color: colorTokens.text }]}>{task.daysRemaining}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
{task.status !== 'completed' && task.status !== 'skipped' && task.currentCount < task.targetCount && (
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.skipButton]}
|
||||
onPress={handleSkipTask}
|
||||
>
|
||||
<MaterialIcons name="skip-next" size={20} color="#6B7280" />
|
||||
<Text style={styles.skipButtonText}>跳过任务</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 评论区域 */}
|
||||
<View style={styles.commentSection}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>评论区域</Text>
|
||||
<View style={styles.commentInputContainer}>
|
||||
<View style={styles.commentAvatar}>
|
||||
<Image
|
||||
source={require('@/assets/images/Sealife.jpeg')}
|
||||
style={styles.commentAvatarImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.commentInputWrapper}>
|
||||
<TextInput
|
||||
style={[styles.commentInput, {
|
||||
color: colorTokens.text,
|
||||
backgroundColor: '#F3F4F6'
|
||||
}]}
|
||||
placeholder="写评论..."
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={comment}
|
||||
onChangeText={setComment}
|
||||
multiline
|
||||
maxLength={500}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, {
|
||||
backgroundColor: comment.trim() ? '#6B7280' : '#D1D5DB'
|
||||
}]}
|
||||
onPress={handleSendComment}
|
||||
disabled={!comment.trim()}
|
||||
>
|
||||
<MaterialIcons name="send" size={16} color="#FFFFFF" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
completeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#7A5AF8',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
taskIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
titleSection: {
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
taskTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
lineHeight: 28,
|
||||
marginBottom: 4,
|
||||
},
|
||||
createdDate: {
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
opacity: 0.7,
|
||||
},
|
||||
statusContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
statusTag: {
|
||||
alignSelf: 'flex-end',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
statusTagText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
imagePlaceholder: {
|
||||
height: 240,
|
||||
backgroundColor: '#F9FAFB',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E5E7EB',
|
||||
borderStyle: 'dashed',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#9CA3AF',
|
||||
marginTop: 8,
|
||||
},
|
||||
descriptionSection: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
descriptionText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontWeight: '400',
|
||||
},
|
||||
infoSection: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
infoItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
priorityTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#EF4444',
|
||||
},
|
||||
priorityTagText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
difficultyTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
difficultyTagText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
progressSection: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
progressBar: {
|
||||
height: 6,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 3,
|
||||
marginBottom: 16,
|
||||
overflow: 'visible',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
},
|
||||
progressGlow: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 8,
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
progressTextContainer: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -6,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
zIndex: 1,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
},
|
||||
progressInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
progressItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '400',
|
||||
marginBottom: 4,
|
||||
},
|
||||
progressValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
commentSection: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
commentInputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
commentAvatar: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
commentAvatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
commentInputWrapper: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
commentInput: {
|
||||
flex: 1,
|
||||
minHeight: 36,
|
||||
maxHeight: 120,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
fontSize: 15,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
sendButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
actionButtons: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
skipButton: {
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
skipButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
},
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { TaskCard } from '@/components/TaskCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { tasksApi } from '@/services/tasksApi';
|
||||
import { TaskListItem } from '@/types/goals';
|
||||
import { getTodayIndexInMonth } from '@/utils/date';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, FlatList, RefreshControl, StatusBar, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function GoalsDetailScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const router = useRouter();
|
||||
|
||||
// 本地状态管理
|
||||
const [tasks, setTasks] = useState<TaskListItem[]>([]);
|
||||
const [tasksLoading, setTasksLoading] = useState(false);
|
||||
const [tasksError, setTasksError] = useState<string | null>(null);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 日期选择器相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
|
||||
// 加载任务列表
|
||||
const loadTasks = async (targetDate?: Date) => {
|
||||
try {
|
||||
setTasksLoading(true);
|
||||
setTasksError(null);
|
||||
|
||||
const dateToUse = targetDate || selectedDate;
|
||||
console.log('Loading tasks for date:', dayjs(dateToUse).format('YYYY-MM-DD'));
|
||||
|
||||
const response = await tasksApi.getTasks({
|
||||
startDate: dayjs(dateToUse).startOf('day').toISOString(),
|
||||
endDate: dayjs(dateToUse).endOf('day').toISOString(),
|
||||
});
|
||||
|
||||
console.log('Tasks API response:', response);
|
||||
setTasks(response.list || []);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
setTasksError(error.message || '获取任务列表失败');
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setTasksLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 页面聚焦时重新加载数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('useFocusEffect - loading tasks');
|
||||
loadTasks();
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadTasks();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理错误提示
|
||||
useEffect(() => {
|
||||
if (tasksError) {
|
||||
Alert.alert('错误', tasksError);
|
||||
setTasksError(null);
|
||||
}
|
||||
}, [tasksError]);
|
||||
|
||||
|
||||
|
||||
// 日期选择处理
|
||||
const onSelectDate = async (index: number, date: Date) => {
|
||||
console.log('Date selected:', dayjs(date).format('YYYY-MM-DD'));
|
||||
setSelectedIndex(index);
|
||||
setSelectedDate(date);
|
||||
// 重新加载对应日期的任务数据
|
||||
await loadTasks(date);
|
||||
};
|
||||
|
||||
// 根据选中日期筛选任务,并将已完成的任务放到最后
|
||||
const filteredTasks = useMemo(() => {
|
||||
const selected = dayjs(selectedDate);
|
||||
const filtered = tasks.filter(task => {
|
||||
if (task.status === 'skipped') return false;
|
||||
const taskDate = dayjs(task.startDate);
|
||||
return taskDate.isSame(selected, 'day');
|
||||
});
|
||||
|
||||
// 对筛选结果进行排序:已完成的任务放到最后
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aCompleted = a.status === 'completed';
|
||||
const bCompleted = b.status === 'completed';
|
||||
|
||||
// 如果a已完成而b未完成,a排在后面
|
||||
if (aCompleted && !bCompleted) {
|
||||
return 1;
|
||||
}
|
||||
// 如果b已完成而a未完成,b排在后面
|
||||
if (bCompleted && !aCompleted) {
|
||||
return -1;
|
||||
}
|
||||
// 如果都已完成或都未完成,保持原有顺序
|
||||
return 0;
|
||||
});
|
||||
}, [selectedDate, tasks]);
|
||||
|
||||
const handleBackPress = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 渲染任务项
|
||||
const renderTaskItem = ({ item }: { item: TaskListItem }) => (
|
||||
<TaskCard
|
||||
task={item}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmptyState = () => {
|
||||
const selectedDateStr = dayjs(selectedDate).format('YYYY年M月D日');
|
||||
|
||||
if (tasksLoading) {
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||
加载中...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyStateTitle, { color: colorTokens.text }]}>
|
||||
暂无任务
|
||||
</Text>
|
||||
<Text style={[styles.emptyStateSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{selectedDateStr} 没有任务安排
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<StatusBar
|
||||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#F0F9FF', '#E0F2FE']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 标题区域 */}
|
||||
<HeaderBar
|
||||
title="任务列表"
|
||||
onBack={handleBackPress}
|
||||
transparent={true}
|
||||
withSafeTop={false}
|
||||
/>
|
||||
|
||||
{/* 日期选择器 */}
|
||||
<View style={styles.dateSelector}>
|
||||
<DateSelector
|
||||
selectedIndex={selectedIndex}
|
||||
onDateSelect={onSelectDate}
|
||||
showMonthTitle={true}
|
||||
disableFutureDates={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<View style={styles.taskListContainer}>
|
||||
<FlatList
|
||||
data={filteredTasks}
|
||||
renderItem={renderTaskItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.taskList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={['#0EA5E9']}
|
||||
tintColor="#0EA5E9"
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
opacity: 0.6,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 日期选择器样式
|
||||
dateSelector: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
taskListContainer: {
|
||||
flex: 1,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
taskList: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: TAB_BAR_HEIGHT + TAB_BAR_BOTTOM_OFFSET + 20,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyStateSubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
@@ -1,618 +0,0 @@
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { PlanGoal } from '@/services/trainingPlanApi';
|
||||
import {
|
||||
clearError,
|
||||
loadPlans,
|
||||
saveDraftAsPlan,
|
||||
setGoal,
|
||||
setMode,
|
||||
setName,
|
||||
setPreferredTime,
|
||||
setSessionsPerWeek,
|
||||
setStartDate,
|
||||
setStartDateNextMonday,
|
||||
setStartWeight,
|
||||
toggleDayOfWeek,
|
||||
} from '@/store/trainingPlanSlice';
|
||||
|
||||
const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
const GOALS: { key: PlanGoal; title: string; desc: string }[] = [
|
||||
{ key: 'postpartum_recovery', title: '产后恢复', desc: '温和激活,核心重建' },
|
||||
{ key: 'posture_correction', title: '体态矫正', desc: '打开胸肩,改善圆肩驼背' },
|
||||
{ key: 'fat_loss', title: '减脂塑形', desc: '全身燃脂,线条雕刻' },
|
||||
{ key: 'core_strength', title: '核心力量', desc: '核心稳定,提升运动表现' },
|
||||
{ key: 'flexibility', title: '柔韧灵活', desc: '拉伸延展,释放紧张' },
|
||||
{ key: 'rehab', title: '康复保健', desc: '循序渐进,科学修复' },
|
||||
{ key: 'stress_relief', title: '释压放松', desc: '舒缓身心,改善睡眠' },
|
||||
];
|
||||
|
||||
export default function TrainingPlanCreateScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { draft, loading, error, editingId } = useAppSelector((s) => s.trainingPlan);
|
||||
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||
const [weightInput, setWeightInput] = useState<string>('');
|
||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadPlans());
|
||||
}, [dispatch]);
|
||||
|
||||
// 如果带有 id,加载详情并进入编辑模式
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
dispatch({ type: 'trainingPlan/clearError' } as any);
|
||||
dispatch((require('@/store/trainingPlanSlice') as any).loadPlanForEdit(id as string));
|
||||
} else {
|
||||
// 离开编辑模式
|
||||
dispatch((require('@/store/trainingPlanSlice') as any).setEditingId(null));
|
||||
}
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft.startWeightKg && !weightInput) setWeightInput(String(draft.startWeightKg));
|
||||
}, [draft.startWeightKg]);
|
||||
|
||||
const selectedCount = draft.mode === 'daysOfWeek' ? draft.daysOfWeek.length : draft.sessionsPerWeek;
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
if (!draft.goal) return false;
|
||||
if (draft.mode === 'daysOfWeek' && draft.daysOfWeek.length === 0) return false;
|
||||
if (draft.mode === 'sessionsPerWeek' && draft.sessionsPerWeek <= 0) return false;
|
||||
return true;
|
||||
}, [draft]);
|
||||
|
||||
const formattedStartDate = useMemo(() => {
|
||||
const d = new Date(draft.startDate);
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).format(d);
|
||||
} catch {
|
||||
return d.toLocaleDateString('zh-CN');
|
||||
}
|
||||
}, [draft.startDate]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (editingId) {
|
||||
await dispatch((require('@/store/trainingPlanSlice') as any).updatePlanFromDraft()).unwrap();
|
||||
} else {
|
||||
await dispatch(saveDraftAsPlan()).unwrap();
|
||||
}
|
||||
router.back();
|
||||
} catch (error) {
|
||||
// 错误已经在Redux中处理,这里可以显示额外的用户反馈
|
||||
console.error('保存训练计划失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
// 3秒后自动清除错误
|
||||
const timer = setTimeout(() => {
|
||||
dispatch(clearError());
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error, dispatch]);
|
||||
|
||||
const openDatePicker = () => {
|
||||
const base = draft.startDate ? new Date(draft.startDate) : new Date();
|
||||
base.setHours(0, 0, 0, 0);
|
||||
setPickerDate(base);
|
||||
setDatePickerVisible(true);
|
||||
};
|
||||
const closeDatePicker = () => setDatePickerVisible(false);
|
||||
const onConfirmDate = (date: Date) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const picked = new Date(date);
|
||||
picked.setHours(0, 0, 0, 0);
|
||||
const finalDate = picked < today ? today : picked;
|
||||
dispatch(setStartDate(finalDate.toISOString()));
|
||||
closeDatePicker();
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.container}>
|
||||
<HeaderBar title={editingId ? '编辑训练计划' : '新建训练计划'} onBack={() => router.back()} withSafeTop={false} transparent />
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
|
||||
<ThemedText style={styles.title}>制定你的训练计划</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>选择你的训练节奏与目标,我们将为你生成合适的普拉提安排。</ThemedText>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<ThemedText style={styles.errorText}>⚠️ {error}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>计划名称</ThemedText>
|
||||
<TextInput
|
||||
placeholder="为你的训练计划起个名字(可选)"
|
||||
value={draft.name || ''}
|
||||
onChangeText={(text) => dispatch(setName(text))}
|
||||
style={styles.nameInput}
|
||||
maxLength={50}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>训练频率</ThemedText>
|
||||
<View style={styles.segment}>
|
||||
<Pressable
|
||||
onPress={() => dispatch(setMode('daysOfWeek'))}
|
||||
style={[styles.segmentItem, draft.mode === 'daysOfWeek' && styles.segmentItemActive]}
|
||||
>
|
||||
<ThemedText style={[styles.segmentText, draft.mode === 'daysOfWeek' && styles.segmentTextActive]}>按星期选择</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => dispatch(setMode('sessionsPerWeek'))}
|
||||
style={[styles.segmentItem, draft.mode === 'sessionsPerWeek' && styles.segmentItemActive]}
|
||||
>
|
||||
<ThemedText style={[styles.segmentText, draft.mode === 'sessionsPerWeek' && styles.segmentTextActive]}>每周次数</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{draft.mode === 'daysOfWeek' ? (
|
||||
<View style={styles.weekRow}>
|
||||
{WEEK_DAYS.map((d, i) => {
|
||||
const active = draft.daysOfWeek.includes(i);
|
||||
return (
|
||||
<Pressable key={i} onPress={() => dispatch(toggleDayOfWeek(i))} style={[styles.dayChip, active && styles.dayChipActive]}>
|
||||
<ThemedText style={[styles.dayChipText, active && styles.dayChipTextActive]}>{d}</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.sliderRow}>
|
||||
<ThemedText style={styles.sliderLabel}>每周训练</ThemedText>
|
||||
<View style={styles.counter}>
|
||||
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.max(1, draft.sessionsPerWeek - 1)))} style={styles.counterBtn}>
|
||||
<ThemedText style={styles.counterBtnText}>-</ThemedText>
|
||||
</Pressable>
|
||||
<ThemedText style={styles.counterValue}>{draft.sessionsPerWeek}</ThemedText>
|
||||
<Pressable onPress={() => dispatch(setSessionsPerWeek(Math.min(7, draft.sessionsPerWeek + 1)))} style={styles.counterBtn}>
|
||||
<ThemedText style={styles.counterBtnText}>+</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ThemedText style={styles.sliderSuffix}>次</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ThemedText style={styles.helper}>已选择:{selectedCount} 次/周</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>训练目标</ThemedText>
|
||||
<View style={styles.goalGrid}>
|
||||
{GOALS.map((g) => {
|
||||
const active = draft.goal === g.key;
|
||||
return (
|
||||
<Pressable key={g.key} onPress={() => dispatch(setGoal(g.key))} style={[styles.goalItem, active && styles.goalItemActive]}>
|
||||
<ThemedText style={[styles.goalTitle, active && styles.goalTitleActive]}>{g.title}</ThemedText>
|
||||
<ThemedText style={styles.goalDesc}>{g.desc}</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<ThemedText style={styles.cardTitle}>更多选项</ThemedText>
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText style={styles.label}>开始日期</ThemedText>
|
||||
<View style={styles.rowRight}>
|
||||
<Pressable onPress={openDatePicker} style={styles.linkBtn}>
|
||||
<ThemedText style={styles.linkText}>选择日期</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => dispatch(setStartDateNextMonday())} style={[styles.linkBtn, { marginLeft: 8 }]}>
|
||||
<ThemedText style={styles.linkText}>下周一</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
<ThemedText style={styles.dateHint}>{formattedStartDate}</ThemedText>
|
||||
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText style={styles.label}>开始体重 (kg)</ThemedText>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
placeholder="可选"
|
||||
value={weightInput}
|
||||
onChangeText={(t) => {
|
||||
setWeightInput(t);
|
||||
const v = Number(t);
|
||||
dispatch(setStartWeight(Number.isFinite(v) ? v : undefined));
|
||||
}}
|
||||
style={styles.input}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.rowBetween, { marginTop: 12 }]}>
|
||||
<ThemedText style={styles.label}>偏好时间段</ThemedText>
|
||||
<View style={styles.segmentSmall}>
|
||||
{(['morning', 'noon', 'evening', ''] as const).map((k) => (
|
||||
<Pressable key={k || 'none'} onPress={() => dispatch(setPreferredTime(k))} style={[styles.segmentItemSmall, draft.preferredTimeOfDay === k && styles.segmentItemActiveSmall]}>
|
||||
<ThemedText style={[styles.segmentTextSmall, draft.preferredTimeOfDay === k && styles.segmentTextActiveSmall]}>{k === 'morning' ? '晨练' : k === 'noon' ? '午间' : k === 'evening' ? '晚间' : '不限'}</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable disabled={!canSave || loading} onPress={handleSave} style={[styles.primaryBtn, (!canSave || loading) && styles.primaryBtnDisabled]}>
|
||||
<ThemedText style={styles.primaryBtnText}>
|
||||
{loading ? (editingId ? '更新中...' : '创建中...') : canSave ? (editingId ? '更新计划' : '生成计划') : '请先选择目标/频率'}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ height: 32 }} />
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
<Modal
|
||||
visible={datePickerVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={closeDatePicker}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||
<View style={styles.modalSheet}>
|
||||
<DateTimePicker
|
||||
value={pickerDate}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={new Date()}
|
||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
} else {
|
||||
if (event.type === 'set' && date) {
|
||||
onConfirmDate(date);
|
||||
} else {
|
||||
closeDatePicker();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<ThemedText style={styles.modalBtnText}>取消</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<ThemedText style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F7F8FA',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F7F8FA',
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#1A1A1A',
|
||||
lineHeight: 36,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#5E6468',
|
||||
marginTop: 6,
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginTop: 14,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 3,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0F172A',
|
||||
marginBottom: 12,
|
||||
},
|
||||
segment: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F1F5F9',
|
||||
padding: 4,
|
||||
borderRadius: 999,
|
||||
},
|
||||
segmentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
segmentItemActive: {
|
||||
backgroundColor: palette.primary,
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: palette.ink,
|
||||
},
|
||||
weekRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 14,
|
||||
},
|
||||
dayChip: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dayChipActive: {
|
||||
backgroundColor: '#E0F8A2',
|
||||
borderWidth: 2,
|
||||
borderColor: palette.primary,
|
||||
},
|
||||
dayChipText: {
|
||||
fontSize: 16,
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
dayChipTextActive: {
|
||||
color: '#0F172A',
|
||||
},
|
||||
sliderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
sliderLabel: {
|
||||
fontSize: 16,
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
counter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: 12,
|
||||
},
|
||||
counterBtn: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
counterBtnText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
counterValue: {
|
||||
width: 44,
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
sliderSuffix: {
|
||||
marginLeft: 8,
|
||||
color: '#475569',
|
||||
},
|
||||
helper: {
|
||||
marginTop: 10,
|
||||
color: '#5E6468',
|
||||
},
|
||||
goalGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
goalItem: {
|
||||
width: '48%',
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
goalItemActive: {
|
||||
backgroundColor: '#E0F8A2',
|
||||
borderColor: palette.primary,
|
||||
borderWidth: 2,
|
||||
},
|
||||
goalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#0F172A',
|
||||
},
|
||||
goalTitleActive: {
|
||||
color: '#0F172A',
|
||||
},
|
||||
goalDesc: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#5E6468',
|
||||
lineHeight: 16,
|
||||
},
|
||||
rowBetween: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 6,
|
||||
},
|
||||
rowRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontWeight: '700',
|
||||
},
|
||||
linkBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
linkText: {
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
dateHint: {
|
||||
marginTop: 6,
|
||||
color: '#5E6468',
|
||||
},
|
||||
input: {
|
||||
marginLeft: 12,
|
||||
backgroundColor: '#F1F5F9',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
minWidth: 88,
|
||||
textAlign: 'right',
|
||||
color: '#0F172A',
|
||||
},
|
||||
segmentSmall: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F1F5F9',
|
||||
padding: 3,
|
||||
borderRadius: 999,
|
||||
},
|
||||
segmentItemSmall: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
marginHorizontal: 3,
|
||||
},
|
||||
segmentItemActiveSmall: {
|
||||
backgroundColor: palette.primary,
|
||||
},
|
||||
segmentTextSmall: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontWeight: '700',
|
||||
},
|
||||
segmentTextActiveSmall: {
|
||||
color: palette.ink,
|
||||
},
|
||||
primaryBtn: {
|
||||
marginTop: 18,
|
||||
backgroundColor: palette.primary,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: palette.ink,
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
},
|
||||
modalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 8,
|
||||
gap: 12,
|
||||
},
|
||||
modalBtn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
modalBtnPrimary: {
|
||||
backgroundColor: palette.primary,
|
||||
},
|
||||
modalBtnText: {
|
||||
color: '#334155',
|
||||
fontWeight: '700',
|
||||
},
|
||||
modalBtnTextPrimary: {
|
||||
color: palette.ink,
|
||||
},
|
||||
|
||||
// 计划名称输入框
|
||||
nameInput: {
|
||||
backgroundColor: '#F1F5F9',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
color: '#0F172A',
|
||||
marginTop: 8,
|
||||
},
|
||||
|
||||
// 错误状态
|
||||
errorContainer: {
|
||||
backgroundColor: 'rgba(237,71,71,0.1)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(237,71,71,0.2)',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
color: '#ED4747',
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { analyzeFoodFromText } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
import { triggerHapticFeedback } from '@/utils/haptics';
|
||||
@@ -22,10 +24,12 @@ import {
|
||||
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
|
||||
|
||||
export default function VoiceRecordScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>();
|
||||
const dispatch = useAppDispatch();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
// 状态管理
|
||||
const [recordState, setRecordState] = useState<VoiceRecordState>('idle');
|
||||
@@ -220,6 +224,12 @@ export default function VoiceRecordScreen() {
|
||||
|
||||
// 开始录音
|
||||
const startRecording = async () => {
|
||||
// 先验证登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 重置状态
|
||||
setRecognizedText('');
|
||||
@@ -290,6 +300,12 @@ export default function VoiceRecordScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 先验证登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
triggerHapticFeedback('impactMedium');
|
||||
setRecordState('analyzing');
|
||||
@@ -460,6 +476,10 @@ export default function VoiceRecordScreen() {
|
||||
variant="elevated"
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 上半部分:介绍 */}
|
||||
<View style={styles.topSection}>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getQuickWaterAmount, getWaterReminderSettings, setWaterReminderSettings as saveWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
@@ -12,12 +10,9 @@ import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
@@ -25,6 +20,7 @@ import {
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface WaterDetailProps {
|
||||
@@ -32,6 +28,8 @@ interface WaterDetailProps {
|
||||
}
|
||||
|
||||
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -194,7 +192,9 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
|
||||
@@ -243,6 +243,7 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f3f4fb',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
@@ -253,23 +254,23 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
top: 80,
|
||||
right: 30,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#4F5BD5',
|
||||
opacity: 0.08,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
bottom: 100,
|
||||
left: -20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#4F5BD5',
|
||||
opacity: 0.06,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
@@ -278,44 +279,49 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 20,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
marginBottom: 36,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 20,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 24,
|
||||
letterSpacing: -0.5,
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
subsectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
letterSpacing: -0.3,
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
lineHeight: 18,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
color: '#6f7ba7',
|
||||
},
|
||||
// 饮水记录相关样式
|
||||
recordsList: {
|
||||
gap: 12,
|
||||
gap: 16,
|
||||
},
|
||||
recordCardContainer: {
|
||||
// iOS 阴影效果
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
// iOS 阴影效果 - 增强阴影效果
|
||||
shadowColor: 'rgba(30, 41, 59, 0.18)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 16,
|
||||
// Android 阴影效果
|
||||
elevation: 2,
|
||||
elevation: 6,
|
||||
},
|
||||
recordCard: {
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
borderRadius: 20,
|
||||
padding: 18,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
recordMainContent: {
|
||||
flexDirection: 'row',
|
||||
@@ -323,44 +329,47 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
recordIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.08)',
|
||||
},
|
||||
recordIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
recordInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
marginLeft: 16,
|
||||
},
|
||||
recordLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
marginBottom: 6,
|
||||
},
|
||||
recordTimeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
gap: 6,
|
||||
},
|
||||
recordAmountContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
recordAmount: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#4F5BD5',
|
||||
},
|
||||
deleteSwipeButton: {
|
||||
backgroundColor: '#EF4444',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
borderRadius: 16,
|
||||
marginLeft: 12,
|
||||
},
|
||||
deleteSwipeButtonText: {
|
||||
color: '#FFFFFF',
|
||||
@@ -369,47 +378,61 @@ const styles = StyleSheet.create({
|
||||
marginTop: 4,
|
||||
},
|
||||
recordTimeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#6f7ba7',
|
||||
},
|
||||
recordNote: {
|
||||
marginTop: 8,
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.04)',
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
fontStyle: 'italic',
|
||||
fontStyle: 'normal',
|
||||
lineHeight: 20,
|
||||
color: '#5f6a97',
|
||||
},
|
||||
recordsSummary: {
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginTop: 24,
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.12)',
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 6,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
summaryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
summaryGoal: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6f7ba7',
|
||||
},
|
||||
noRecordsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
gap: 16,
|
||||
paddingVertical: 60,
|
||||
gap: 20,
|
||||
},
|
||||
noRecordsText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
lineHeight: 24,
|
||||
color: '#6f7ba7',
|
||||
},
|
||||
noRecordsSubText: {
|
||||
fontSize: 13,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
opacity: 0.7,
|
||||
lineHeight: 20,
|
||||
color: '#9ba3c7',
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -476,10 +499,14 @@ const styles = StyleSheet.create({
|
||||
// color will be set dynamically
|
||||
},
|
||||
settingsButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.24)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
settingsModalSheet: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -22,8 +22,10 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
const WaterReminderSettings: React.FC = () => {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
@@ -186,7 +188,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 开启/关闭提醒 */}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { getQuickWaterAmount, getWaterReminderSettings, setQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Picker } from '@react-native-picker/picker';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -15,15 +15,16 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
const WaterSettings: React.FC = () => {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
@@ -143,7 +144,7 @@ const WaterSettings: React.FC = () => {
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingTop: safeAreaTop }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 设置列表 */}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Colors } from '@/constants/Colors';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -22,6 +24,8 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function WeightRecordsPage() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||
@@ -104,6 +108,13 @@ export default function WeightRecordsPage() {
|
||||
if (pickerType === 'current') {
|
||||
// Update current weight in profile and add weight record
|
||||
await dispatch(updateUserProfile({ weight: weight }) as any);
|
||||
|
||||
// 记录体重后尝试请求应用评分(延迟1秒,避免阻塞主流程)
|
||||
setTimeout(() => {
|
||||
appStoreReviewService.requestReview().catch((error) => {
|
||||
console.error('应用评分请求失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
} else if (pickerType === 'initial') {
|
||||
// Update initial weight in profile
|
||||
console.log('更新初始体重');
|
||||
@@ -184,8 +195,12 @@ export default function WeightRecordsPage() {
|
||||
<Ionicons name="add" size={24} color="#192126" />
|
||||
</TouchableOpacity>}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
{/* Weight Statistics */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={[styles.statsContainer]}>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{totalWeightLoss.toFixed(1)}kg</Text>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function WorkoutLayout() {
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="today"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'card',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="create-session"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'modal',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="session/[id]"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'card',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, FlatList, SafeAreaView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { loadPlans } from '@/store/trainingPlanSlice';
|
||||
import { createWorkoutSession } from '@/store/workoutSlice';
|
||||
|
||||
const GOAL_TEXT: Record<string, { title: string; color: string; description: string }> = {
|
||||
postpartum_recovery: { title: '产后恢复', color: '#9BE370', description: '温和激活,核心重建' },
|
||||
fat_loss: { title: '减脂塑形', color: '#FFB86B', description: '全身燃脂,线条雕刻' },
|
||||
posture_correction: { title: '体态矫正', color: '#95CCE3', description: '打开胸肩,改善圆肩驼背' },
|
||||
core_strength: { title: '核心力量', color: '#A48AED', description: '核心稳定,提升运动表现' },
|
||||
flexibility: { title: '柔韧灵活', color: '#B0F2A7', description: '拉伸延展,释放紧张' },
|
||||
rehab: { title: '康复保健', color: '#FF8E9E', description: '循序渐进,科学修复' },
|
||||
stress_relief: { title: '释压放松', color: '#9BD1FF', description: '舒缓身心,改善睡眠' },
|
||||
};
|
||||
|
||||
// 动态背景组件
|
||||
function DynamicBackground({ color }: { color: string }) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject}>
|
||||
<LinearGradient
|
||||
colors={['#F9FBF2', '#FFFFFF', '#F5F9F0']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<View style={[styles.backgroundOrb, { backgroundColor: `${color}15` }]} />
|
||||
<View style={[styles.backgroundOrb2, { backgroundColor: `${color}10` }]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateWorkoutSessionScreen() {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const { plans, loading: plansLoading } = useAppSelector((s) => s.trainingPlan);
|
||||
|
||||
const [sessionName, setSessionName] = useState('');
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadPlans());
|
||||
}, [dispatch]);
|
||||
|
||||
// 自动生成会话名称
|
||||
useEffect(() => {
|
||||
if (!sessionName) {
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getMonth() + 1}月${today.getDate()}日`;
|
||||
setSessionName(`${dateStr}训练`);
|
||||
}
|
||||
}, [sessionName]);
|
||||
|
||||
const selectedPlan = plans.find(p => p.id === selectedPlanId);
|
||||
const goalConfig = selectedPlan?.goal
|
||||
? (GOAL_TEXT[selectedPlan.goal] || { title: '训练', color: palette.primary, description: '开始你的训练之旅' })
|
||||
: { title: '新建训练', color: palette.primary, description: '选择创建方式' };
|
||||
|
||||
// 创建自定义会话
|
||||
const handleCreateCustomSession = async () => {
|
||||
if (creating || !sessionName.trim()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await dispatch(createWorkoutSession({
|
||||
name: sessionName.trim(),
|
||||
scheduledDate: dayjs().format('YYYY-MM-DD')
|
||||
})).unwrap();
|
||||
|
||||
// 创建成功后跳转到选择动作页面
|
||||
router.replace('/training-plan/schedule/select' as any);
|
||||
} catch (error) {
|
||||
console.error('创建训练会话失败:', error);
|
||||
Alert.alert('创建失败', '创建训练会话时出现错误,请稍后重试');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 从训练计划创建会话
|
||||
const handleCreateFromPlan = async () => {
|
||||
if (creating || !selectedPlan || !sessionName.trim()) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await dispatch(createWorkoutSession({
|
||||
name: sessionName.trim(),
|
||||
trainingPlanId: selectedPlan.id,
|
||||
scheduledDate: dayjs().format('YYYY-MM-DD')
|
||||
})).unwrap();
|
||||
|
||||
// 创建成功后返回到训练记录页面
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.error('创建训练会话失败:', error);
|
||||
Alert.alert('创建失败', '创建训练会话时出现错误,请稍后重试');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染训练计划卡片
|
||||
const renderPlanItem = ({ item, index }: { item: any; index: number }) => {
|
||||
const isSelected = item.id === selectedPlanId;
|
||||
const planGoalConfig = GOAL_TEXT[item.goal] || { title: '训练计划', color: palette.primary, description: '开始你的训练之旅' };
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(index * 100)}
|
||||
style={[
|
||||
styles.planCard,
|
||||
{ borderLeftColor: planGoalConfig.color },
|
||||
isSelected && { borderWidth: 2, borderColor: planGoalConfig.color }
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.planCardContent}
|
||||
onPress={() => {
|
||||
setSelectedPlanId(isSelected ? null : item.id);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={styles.planHeader}>
|
||||
<View style={styles.planInfo}>
|
||||
<Text style={styles.planName}>{item.name}</Text>
|
||||
<Text style={[styles.planGoal, { color: planGoalConfig.color }]}>
|
||||
{planGoalConfig.title}
|
||||
</Text>
|
||||
<Text style={styles.methodDescription}>
|
||||
{planGoalConfig.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.planStatus}>
|
||||
{isSelected ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color={planGoalConfig.color} />
|
||||
) : (
|
||||
<View style={[styles.radioButton, { borderColor: planGoalConfig.color }]} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.exercises && item.exercises.length > 0 && (
|
||||
<View style={styles.planStats}>
|
||||
<Text style={styles.statsText}>
|
||||
{item.exercises.length} 个动作
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
{/* 动态背景 */}
|
||||
<DynamicBackground color={goalConfig.color} />
|
||||
|
||||
<SafeAreaView style={styles.contentWrapper}>
|
||||
<HeaderBar
|
||||
title="新建训练"
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
transparent={true}
|
||||
tone="light"
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 会话信息设置 */}
|
||||
<View style={[styles.sessionHeader, { backgroundColor: `${goalConfig.color}20` }]}>
|
||||
<View style={[styles.sessionColorIndicator, { backgroundColor: goalConfig.color }]} />
|
||||
<View style={styles.sessionInfo}>
|
||||
<ThemedText style={styles.sessionTitle}>训练会话设置</ThemedText>
|
||||
<ThemedText style={styles.sessionDescription}>
|
||||
{goalConfig.description}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 会话名称输入 */}
|
||||
<View style={styles.inputSection}>
|
||||
<Text style={styles.inputLabel}>会话名称</Text>
|
||||
<TextInput
|
||||
value={sessionName}
|
||||
onChangeText={setSessionName}
|
||||
placeholder="输入会话名称"
|
||||
placeholderTextColor="#888F92"
|
||||
style={[styles.textInput, { borderColor: `${goalConfig.color}30` }]}
|
||||
maxLength={50}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 创建方式选择 */}
|
||||
<View style={styles.methodSection}>
|
||||
<Text style={styles.sectionTitle}>选择创建方式</Text>
|
||||
|
||||
{/* 自定义会话 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.methodCard, { borderColor: `${goalConfig.color}30` }]}
|
||||
onPress={handleCreateCustomSession}
|
||||
disabled={creating || !sessionName.trim()}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View style={styles.methodIcon}>
|
||||
<Ionicons name="create-outline" size={24} color={goalConfig.color} />
|
||||
</View>
|
||||
<View style={styles.methodInfo}>
|
||||
<Text style={styles.methodTitle}>自定义会话</Text>
|
||||
<Text style={styles.methodDescription}>
|
||||
创建空的训练会话,然后手动添加动作
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 从训练计划导入 */}
|
||||
<View style={styles.planImportSection}>
|
||||
<Text style={styles.methodTitle}>从训练计划导入</Text>
|
||||
<Text style={styles.methodDescription}>
|
||||
选择一个训练计划,将其动作导入到新会话中
|
||||
</Text>
|
||||
|
||||
{plansLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>加载训练计划中...</Text>
|
||||
</View>
|
||||
) : plans.length === 0 ? (
|
||||
<View style={styles.emptyPlansContainer}>
|
||||
<Ionicons name="document-outline" size={32} color="#9CA3AF" />
|
||||
<Text style={styles.emptyPlansText}>暂无训练计划</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.createPlanBtn, { backgroundColor: goalConfig.color }]}
|
||||
onPress={() => router.push('/training-plan/create' as any)}
|
||||
>
|
||||
<Text style={styles.createPlanBtnText}>创建训练计划</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<FlatList
|
||||
data={plans}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderPlanItem}
|
||||
contentContainerStyle={styles.plansList}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEnabled={false}
|
||||
/>
|
||||
|
||||
{selectedPlan && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.confirmBtn,
|
||||
{ backgroundColor: goalConfig.color },
|
||||
(!sessionName.trim() || creating) && { opacity: 0.5 }
|
||||
]}
|
||||
onPress={handleCreateFromPlan}
|
||||
disabled={!sessionName.trim() || creating}
|
||||
>
|
||||
<Text style={styles.confirmBtnText}>
|
||||
{creating ? '创建中...' : `从 "${selectedPlan.name}" 创建会话`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
|
||||
// 动态背景
|
||||
backgroundOrb: {
|
||||
position: 'absolute',
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
top: -150,
|
||||
right: -100,
|
||||
},
|
||||
backgroundOrb2: {
|
||||
position: 'absolute',
|
||||
width: 400,
|
||||
height: 400,
|
||||
borderRadius: 200,
|
||||
bottom: -200,
|
||||
left: -150,
|
||||
},
|
||||
|
||||
// 会话信息头部
|
||||
sessionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
sessionColorIndicator: {
|
||||
width: 4,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
marginRight: 12,
|
||||
},
|
||||
sessionInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
sessionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sessionDescription: {
|
||||
fontSize: 13,
|
||||
color: '#5E6468',
|
||||
opacity: 0.8,
|
||||
},
|
||||
|
||||
// 输入区域
|
||||
inputSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
marginBottom: 8,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#192126',
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 2,
|
||||
},
|
||||
|
||||
// 创建方式区域
|
||||
methodSection: {
|
||||
flex: 1,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 16,
|
||||
},
|
||||
|
||||
// 方式卡片
|
||||
methodCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 2,
|
||||
},
|
||||
methodIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
methodInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
methodTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
},
|
||||
methodDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
lineHeight: 16,
|
||||
},
|
||||
|
||||
// 训练计划导入区域
|
||||
planImportSection: {
|
||||
marginTop: 8,
|
||||
},
|
||||
|
||||
// 训练计划列表
|
||||
plansList: {
|
||||
marginTop: 16,
|
||||
marginBottom: 20,
|
||||
},
|
||||
planCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
borderLeftWidth: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 2,
|
||||
},
|
||||
planCardContent: {
|
||||
padding: 16,
|
||||
},
|
||||
planHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
planInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
planName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
marginBottom: 4,
|
||||
},
|
||||
planGoal: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
},
|
||||
planStatus: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
radioButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
},
|
||||
planStats: {
|
||||
marginTop: 8,
|
||||
},
|
||||
statsText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
},
|
||||
|
||||
// 空状态
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
emptyPlansContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
},
|
||||
emptyPlansText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
marginTop: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
createPlanBtn: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
},
|
||||
createPlanBtnText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
// 确认按钮
|
||||
confirmBtn: {
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
},
|
||||
confirmBtnText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '800',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
805
app/workout/history.tsx
Normal file
@@ -0,0 +1,805 @@
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
||||
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
||||
import {
|
||||
addHealthPermissionListener,
|
||||
checkHealthPermissionStatus,
|
||||
ensureHealthPermissions,
|
||||
fetchWorkoutsForDateRange,
|
||||
getHealthPermissionStatus,
|
||||
getWorkoutTypeDisplayName,
|
||||
HealthPermissionStatus,
|
||||
removeHealthPermissionListener,
|
||||
WorkoutActivityType,
|
||||
WorkoutData,
|
||||
} from '@/utils/health';
|
||||
|
||||
type WorkoutSection = {
|
||||
title: string;
|
||||
data: WorkoutData[];
|
||||
};
|
||||
|
||||
const ICON_MAP: Partial<Record<WorkoutActivityType, keyof typeof MaterialCommunityIcons.glyphMap>> = {
|
||||
// 球类运动
|
||||
[WorkoutActivityType.AmericanFootball]: 'football',
|
||||
[WorkoutActivityType.Archery]: 'target',
|
||||
[WorkoutActivityType.AustralianFootball]: 'football',
|
||||
[WorkoutActivityType.Badminton]: 'tennis',
|
||||
[WorkoutActivityType.Baseball]: 'baseball',
|
||||
[WorkoutActivityType.Basketball]: 'basketball',
|
||||
[WorkoutActivityType.Bowling]: 'bowling',
|
||||
[WorkoutActivityType.Boxing]: 'boxing-glove',
|
||||
[WorkoutActivityType.Cricket]: 'cricket',
|
||||
[WorkoutActivityType.Fencing]: 'sword',
|
||||
[WorkoutActivityType.Golf]: 'golf',
|
||||
[WorkoutActivityType.Handball]: 'basketball',
|
||||
[WorkoutActivityType.Hockey]: 'hockey-sticks',
|
||||
[WorkoutActivityType.Lacrosse]: 'tennis',
|
||||
[WorkoutActivityType.Racquetball]: 'tennis',
|
||||
[WorkoutActivityType.Soccer]: 'soccer',
|
||||
[WorkoutActivityType.Softball]: 'baseball',
|
||||
[WorkoutActivityType.Squash]: 'tennis',
|
||||
[WorkoutActivityType.TableTennis]: 'table-tennis',
|
||||
[WorkoutActivityType.Tennis]: 'tennis',
|
||||
[WorkoutActivityType.Volleyball]: 'volleyball',
|
||||
[WorkoutActivityType.WaterPolo]: 'swim',
|
||||
[WorkoutActivityType.Pickleball]: 'tennis',
|
||||
|
||||
// 水上运动
|
||||
[WorkoutActivityType.Swimming]: 'swim',
|
||||
[WorkoutActivityType.Sailing]: 'sail-boat',
|
||||
[WorkoutActivityType.SurfingSports]: 'waves',
|
||||
[WorkoutActivityType.WaterFitness]: 'swim',
|
||||
[WorkoutActivityType.WaterSports]: 'swim',
|
||||
[WorkoutActivityType.UnderwaterDiving]: 'swim',
|
||||
|
||||
// 跑步和步行
|
||||
[WorkoutActivityType.Running]: 'run',
|
||||
[WorkoutActivityType.Walking]: 'walk',
|
||||
[WorkoutActivityType.Hiking]: 'hiking',
|
||||
[WorkoutActivityType.StairClimbing]: 'stairs',
|
||||
[WorkoutActivityType.Stairs]: 'stairs',
|
||||
|
||||
// 骑行
|
||||
[WorkoutActivityType.Cycling]: 'bike',
|
||||
[WorkoutActivityType.HandCycling]: 'bike',
|
||||
|
||||
// 滑雪和滑冰
|
||||
[WorkoutActivityType.CrossCountrySkiing]: 'ski',
|
||||
[WorkoutActivityType.DownhillSkiing]: 'ski',
|
||||
[WorkoutActivityType.Snowboarding]: 'snowboard',
|
||||
[WorkoutActivityType.SkatingSports]: 'skateboarding',
|
||||
[WorkoutActivityType.SnowSports]: 'ski',
|
||||
|
||||
// 力量训练
|
||||
[WorkoutActivityType.FunctionalStrengthTraining]: 'weight-lifter',
|
||||
[WorkoutActivityType.TraditionalStrengthTraining]: 'dumbbell',
|
||||
[WorkoutActivityType.CrossTraining]: 'arm-flex',
|
||||
[WorkoutActivityType.CoreTraining]: 'arm-flex',
|
||||
|
||||
// 有氧运动
|
||||
[WorkoutActivityType.Elliptical]: 'bike',
|
||||
[WorkoutActivityType.Rowing]: 'rowing',
|
||||
[WorkoutActivityType.MixedCardio]: 'heart-pulse',
|
||||
[WorkoutActivityType.MixedMetabolicCardioTraining]: 'heart-pulse',
|
||||
[WorkoutActivityType.HighIntensityIntervalTraining]: 'run-fast',
|
||||
[WorkoutActivityType.JumpRope]: 'skip-forward',
|
||||
[WorkoutActivityType.StepTraining]: 'stairs',
|
||||
|
||||
// 舞蹈和身心训练
|
||||
[WorkoutActivityType.Dance]: 'music',
|
||||
[WorkoutActivityType.DanceInspiredTraining]: 'music',
|
||||
[WorkoutActivityType.CardioDance]: 'music',
|
||||
[WorkoutActivityType.SocialDance]: 'music',
|
||||
[WorkoutActivityType.Yoga]: 'meditation',
|
||||
[WorkoutActivityType.MindAndBody]: 'meditation',
|
||||
[WorkoutActivityType.TaiChi]: 'meditation',
|
||||
[WorkoutActivityType.Pilates]: 'meditation',
|
||||
[WorkoutActivityType.Barre]: 'meditation',
|
||||
[WorkoutActivityType.Flexibility]: 'meditation',
|
||||
[WorkoutActivityType.Cooldown]: 'meditation',
|
||||
[WorkoutActivityType.PreparationAndRecovery]: 'meditation',
|
||||
|
||||
// 户外运动
|
||||
[WorkoutActivityType.Climbing]: 'hiking',
|
||||
[WorkoutActivityType.EquestrianSports]: 'horse',
|
||||
[WorkoutActivityType.Fishing]: 'target',
|
||||
[WorkoutActivityType.Hunting]: 'target',
|
||||
[WorkoutActivityType.PaddleSports]: 'rowing',
|
||||
|
||||
// 综合运动
|
||||
[WorkoutActivityType.SwimBikeRun]: 'run-fast',
|
||||
[WorkoutActivityType.Transition]: 'swap-horizontal-variant',
|
||||
[WorkoutActivityType.Play]: 'gamepad-variant',
|
||||
[WorkoutActivityType.FitnessGaming]: 'gamepad-variant',
|
||||
[WorkoutActivityType.DiscSports]: 'target',
|
||||
|
||||
// 其他
|
||||
[WorkoutActivityType.Other]: 'arm-flex',
|
||||
[WorkoutActivityType.MartialArts]: 'karate',
|
||||
[WorkoutActivityType.Kickboxing]: 'boxing-glove',
|
||||
[WorkoutActivityType.Gymnastics]: 'human',
|
||||
[WorkoutActivityType.TrackAndField]: 'run-fast',
|
||||
[WorkoutActivityType.WheelchairWalkPace]: 'wheelchair',
|
||||
[WorkoutActivityType.WheelchairRunPace]: 'wheelchair',
|
||||
[WorkoutActivityType.Curling]: 'target',
|
||||
};
|
||||
|
||||
type ActivitySummary = {
|
||||
type: WorkoutActivityType;
|
||||
duration: number;
|
||||
count: number;
|
||||
displayName: string;
|
||||
iconName: keyof typeof MaterialCommunityIcons.glyphMap;
|
||||
};
|
||||
|
||||
type MonthlyStatsInfo = {
|
||||
items: ActivitySummary[];
|
||||
totalDuration: number;
|
||||
totalCount: number;
|
||||
monthStart: string;
|
||||
monthEnd: string;
|
||||
snapshotDate: string;
|
||||
};
|
||||
|
||||
const MONTHLY_STAT_COLORS = [
|
||||
{ background: '#FDE9F4', pill: '#F8CDE2', bar: '#F9CFE3', icon: '#2F2965', label: '#5A648C' },
|
||||
{ background: '#FFF3D6', pill: '#FFE3A4', bar: '#FFE0A6', icon: '#2F2965', label: '#5A648C' },
|
||||
{ background: '#E3F5F3', pill: '#CBEAE4', bar: '#D7EEE8', icon: '#2F2965', label: '#5A648C' },
|
||||
];
|
||||
|
||||
function formatDurationShort(durationInSeconds: number): string {
|
||||
const totalMinutes = Math.max(Math.round(durationInSeconds / 60), 1);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
// 扩展 dayjs 以支持 isBetween 插件
|
||||
dayjs.extend(isBetween);
|
||||
|
||||
function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
|
||||
const now = dayjs();
|
||||
const monthStart = now.startOf('month');
|
||||
const monthEnd = now.endOf('month');
|
||||
|
||||
const monthlyEntries = workouts.filter((workout) => {
|
||||
const workoutDate = dayjs(workout.startDate || workout.endDate);
|
||||
if (!workoutDate.isValid()) {
|
||||
return false;
|
||||
}
|
||||
return workoutDate.isBetween(monthStart, monthEnd, 'day', '[]');
|
||||
});
|
||||
|
||||
if (monthlyEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryMap = monthlyEntries.reduce<Record<string, ActivitySummary>>((acc, workout) => {
|
||||
const type = workout.workoutActivityType;
|
||||
if (type === undefined || type === null) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const mapKey = String(type);
|
||||
|
||||
if (!acc[mapKey]) {
|
||||
acc[mapKey] = {
|
||||
type,
|
||||
duration: 0,
|
||||
count: 0,
|
||||
displayName: getWorkoutTypeDisplayName(type),
|
||||
iconName: ICON_MAP[type as WorkoutActivityType] || 'run',
|
||||
};
|
||||
}
|
||||
|
||||
acc[mapKey].duration += workout.duration || 0;
|
||||
acc[mapKey].count += 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const items = Object.values(summaryMap).sort((a, b) => b.duration - a.duration);
|
||||
const totalDuration = monthlyEntries.reduce((sum, workout) => sum + (workout.duration || 0), 0);
|
||||
|
||||
return {
|
||||
items,
|
||||
totalDuration,
|
||||
totalCount: monthlyEntries.length,
|
||||
monthStart: monthStart.format('YYYY-MM-DD'),
|
||||
monthEnd: monthEnd.format('YYYY-MM-DD'),
|
||||
snapshotDate: now.format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
|
||||
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
|
||||
if (!totalCalories || !durationInSeconds) {
|
||||
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
||||
}
|
||||
|
||||
const minutes = Math.max(durationInSeconds / 60, 1);
|
||||
const caloriesPerMinute = totalCalories / minutes;
|
||||
|
||||
if (caloriesPerMinute >= 9) {
|
||||
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
|
||||
}
|
||||
|
||||
if (caloriesPerMinute >= 5) {
|
||||
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
|
||||
}
|
||||
|
||||
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' };
|
||||
}
|
||||
|
||||
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
||||
const grouped = workouts.reduce<Record<string, WorkoutData[]>>((acc, workout) => {
|
||||
const dateKey = dayjs(workout.startDate || workout.endDate).format('YYYY-MM-DD');
|
||||
if (!acc[dateKey]) {
|
||||
acc[dateKey] = [];
|
||||
}
|
||||
acc[dateKey].push(workout);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(grouped)
|
||||
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
|
||||
.map((dateKey) => ({
|
||||
title: dayjs(dateKey).format('M月D日'),
|
||||
data: grouped[dateKey]
|
||||
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function WorkoutHistoryScreen() {
|
||||
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedWorkout, setSelectedWorkout] = useState<WorkoutData | null>(null);
|
||||
const [isDetailVisible, setIsDetailVisible] = useState(false);
|
||||
const [detailMetrics, setDetailMetrics] = useState<WorkoutDetailMetrics | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
|
||||
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
||||
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
||||
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let permissionStatus = getHealthPermissionStatus();
|
||||
if (permissionStatus !== HealthPermissionStatus.Authorized) {
|
||||
permissionStatus = await checkHealthPermissionStatus(true);
|
||||
}
|
||||
|
||||
let hasPermission = permissionStatus === HealthPermissionStatus.Authorized;
|
||||
if (!hasPermission) {
|
||||
hasPermission = await ensureHealthPermissions();
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
setSections([]);
|
||||
setError('尚未授予健康数据权限');
|
||||
setMonthlyStats(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const end = dayjs();
|
||||
const start = end.subtract(1, 'month');
|
||||
const workouts = await fetchWorkoutsForDateRange(start.toDate(), end.toDate(), 200);
|
||||
const filteredWorkouts = workouts.filter((workout) => workout.duration && workout.duration > 0);
|
||||
|
||||
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
|
||||
setSections(groupWorkouts(filteredWorkouts));
|
||||
} catch (err) {
|
||||
console.error('加载锻炼历史失败:', err);
|
||||
setError('加载锻炼记录失败,请稍后再试');
|
||||
setSections([]);
|
||||
setMonthlyStats(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory])
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePermissionGranted = () => {
|
||||
loadHistory();
|
||||
};
|
||||
|
||||
addHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
||||
return () => {
|
||||
removeHealthPermissionListener('permissionGranted', handlePermissionGranted);
|
||||
};
|
||||
}, [loadHistory]);
|
||||
|
||||
const headerComponent = useMemo(() => {
|
||||
const statsItems = monthlyStats?.items.slice(0, 3) ?? [];
|
||||
const monthEndDay = monthlyStats
|
||||
? dayjs(monthlyStats.monthEnd).date()
|
||||
: dayjs().endOf('month').date();
|
||||
const snapshotLabel = monthlyStats
|
||||
? dayjs(monthlyStats.snapshotDate).format('M月D日')
|
||||
: dayjs().format('M月D日');
|
||||
const overviewText = monthlyStats
|
||||
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}。`
|
||||
: '本月还没有锻炼记录,动起来收集第一条吧!';
|
||||
const periodText = `统计周期:1日 - ${monthEndDay}日(本月)`;
|
||||
const maxDuration = statsItems[0]?.duration || 1;
|
||||
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
{/* <Text style={styles.headerTitle}>历史</Text>
|
||||
<Text style={styles.headerSubtitle}>最近一个月的锻炼记录</Text> */}
|
||||
|
||||
<View style={styles.monthlyStatsWrapper}>
|
||||
{/* <Text style={styles.monthlyStatsTitle}>统计</Text> */}
|
||||
<View style={styles.monthlyStatsCardShell}>
|
||||
<LinearGradient
|
||||
colors={['#FFFFFF', '#F5F6FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.monthlyStatsCard}
|
||||
>
|
||||
<Text style={styles.statSectionLabel}>锻炼时间</Text>
|
||||
<Text style={styles.statPeriodText}>{periodText}</Text>
|
||||
<Text style={styles.statDescription}>{overviewText}</Text>
|
||||
|
||||
{statsItems.length > 0 ? (
|
||||
statsItems.map((item, index) => {
|
||||
const palette = MONTHLY_STAT_COLORS[index % MONTHLY_STAT_COLORS.length];
|
||||
const ratio = Math.max(Math.min(item.duration / maxDuration, 1), 0.18);
|
||||
return (
|
||||
<View key={String(item.type)} style={styles.summaryRowWrapper}>
|
||||
<View style={[styles.summaryRowBackground, { backgroundColor: palette.background }]}>
|
||||
<View
|
||||
style={[
|
||||
styles.summaryRowFill,
|
||||
{ width: `${ratio * 100}%`, backgroundColor: palette.bar },
|
||||
]}
|
||||
/>
|
||||
<View style={styles.summaryRowInner}>
|
||||
<View style={[styles.summaryBadge, { backgroundColor: palette.pill }]}>
|
||||
<MaterialCommunityIcons name={item.iconName} size={20} color={palette.icon} />
|
||||
<Text style={[styles.summaryCount, { color: palette.icon }]}>X{item.count}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRowContent}>
|
||||
<Text style={[styles.summaryDuration, { color: palette.icon }]}>{formatDurationShort(item.duration)}</Text>
|
||||
<Text style={[styles.summaryActivity, { color: palette.label }]}>{item.displayName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<View style={styles.statEmptyState}>
|
||||
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
|
||||
<Text style={styles.statEmptyText}>本月还没有锻炼数据</Text>
|
||||
</View>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [monthlyStats]);
|
||||
|
||||
const emptyComponent = useMemo(() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
|
||||
<Text style={styles.emptyText}>暂无锻炼记录</Text>
|
||||
<Text style={styles.emptySubText}>完成一次锻炼后即可在此查看详细历史</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
const computeMonthlyOccurrenceText = useCallback((workout: WorkoutData): string | null => {
|
||||
const workoutDate = dayjs(workout.startDate || workout.endDate);
|
||||
if (!workoutDate.isValid() || sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sameMonthWorkouts = sections
|
||||
.flatMap((section) => section.data)
|
||||
.filter((entry) => {
|
||||
const entryDate = dayjs(entry.startDate || entry.endDate);
|
||||
return (
|
||||
entryDate.isValid() &&
|
||||
entryDate.isSame(workoutDate, 'month') &&
|
||||
entry.workoutActivityType === workout.workoutActivityType
|
||||
);
|
||||
});
|
||||
|
||||
const ascending = sameMonthWorkouts.some((entry) => entry.id === workout.id)
|
||||
? sameMonthWorkouts
|
||||
: [...sameMonthWorkouts, workout];
|
||||
|
||||
ascending.sort(
|
||||
(a, b) =>
|
||||
dayjs(a.startDate || a.endDate).valueOf() - dayjs(b.startDate || b.endDate).valueOf()
|
||||
);
|
||||
|
||||
const index = ascending.findIndex((entry) => entry.id === workout.id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
|
||||
return `这是你${workoutDate.format('M月')}的第 ${index + 1} 次${activityLabel}。`;
|
||||
}, [sections]);
|
||||
|
||||
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
try {
|
||||
const metrics = await getWorkoutDetailMetrics(workout);
|
||||
setDetailMetrics(metrics);
|
||||
} catch (err) {
|
||||
console.error('加载锻炼详情失败:', err);
|
||||
setDetailMetrics(null);
|
||||
setDetailError('加载锻炼详情失败,请稍后再试');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
|
||||
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
|
||||
setSelectedIntensity(intensity);
|
||||
setSelectedWorkout(workout);
|
||||
setDetailMetrics(null);
|
||||
setDetailError(null);
|
||||
setMonthOccurrenceText(computeMonthlyOccurrenceText(workout));
|
||||
setIsDetailVisible(true);
|
||||
loadWorkoutDetail(workout);
|
||||
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
|
||||
|
||||
const handleRetryDetail = useCallback(() => {
|
||||
if (selectedWorkout) {
|
||||
loadWorkoutDetail(selectedWorkout);
|
||||
}
|
||||
}, [selectedWorkout, loadWorkoutDetail]);
|
||||
|
||||
const handleCloseDetail = useCallback(() => {
|
||||
setIsDetailVisible(false);
|
||||
}, []);
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
|
||||
const calories = Math.round(item.totalEnergyBurned || 0);
|
||||
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
|
||||
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0);
|
||||
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
|
||||
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
|
||||
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.historyCard}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => handleWorkoutPress(item)}
|
||||
>
|
||||
<View style={styles.cardIconWrapper}>
|
||||
<MaterialCommunityIcons name={iconName} size={28} color="#5C55FF" />
|
||||
</View>
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={styles.cardTitle}>{calories}千卡 · {minutes}分钟</Text>
|
||||
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
|
||||
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>{activityLabel},{time}</Text>
|
||||
</View>
|
||||
|
||||
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [handleWorkoutPress]);
|
||||
|
||||
const renderSectionHeader = useCallback(({ section }: { section: WorkoutSection }) => (
|
||||
<Text style={styles.sectionHeader}>{section.title}</Text>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
<LinearGradient
|
||||
colors={["#F3F5FF", "#FFFFFF"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} />
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#5C55FF" />
|
||||
<Text style={styles.loadingText}>正在加载锻炼记录...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
style={[styles.sectionList, {
|
||||
paddingTop: safeAreaTop
|
||||
}]}
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
ListHeaderComponent={headerComponent}
|
||||
ListEmptyComponent={error ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
|
||||
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : emptyComponent}
|
||||
contentContainerStyle={styles.listContent}
|
||||
stickySectionHeadersEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
<WorkoutDetailModal
|
||||
visible={isDetailVisible}
|
||||
onClose={handleCloseDetail}
|
||||
workout={selectedWorkout}
|
||||
metrics={detailMetrics}
|
||||
loading={detailLoading}
|
||||
intensityBadge={selectedIntensity || undefined}
|
||||
monthOccurrenceText={monthOccurrenceText || undefined}
|
||||
onRetry={detailError ? handleRetryDetail : undefined}
|
||||
errorMessage={detailError}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
sectionList: {
|
||||
flex: 1,
|
||||
},
|
||||
headerContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: '#1F2355',
|
||||
marginBottom: 6,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#677086',
|
||||
},
|
||||
monthlyStatsWrapper: {
|
||||
},
|
||||
monthlyStatsTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#1F2355',
|
||||
marginBottom: 14,
|
||||
},
|
||||
monthlyStatsCardShell: {
|
||||
borderRadius: 28,
|
||||
shadowColor: '#5460E54D',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
elevation: 8,
|
||||
},
|
||||
monthlyStatsCard: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 22,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
gap: 12,
|
||||
},
|
||||
statSectionLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#8289A9',
|
||||
},
|
||||
statPeriodText: {
|
||||
fontSize: 12,
|
||||
color: '#8C95B0',
|
||||
},
|
||||
statDescription: {
|
||||
marginTop: 2,
|
||||
fontSize: 13,
|
||||
color: '#525A7A',
|
||||
lineHeight: 18,
|
||||
},
|
||||
summaryRowWrapper: {
|
||||
marginTop: 12,
|
||||
},
|
||||
summaryRowBackground: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
summaryRowFill: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
summaryRowInner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 18,
|
||||
gap: 14,
|
||||
},
|
||||
summaryBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 999,
|
||||
gap: 6,
|
||||
},
|
||||
summaryRowContent: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
summaryCount: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#2F2965',
|
||||
},
|
||||
summaryDuration: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#2F2965',
|
||||
},
|
||||
summaryActivity: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#565F7F',
|
||||
},
|
||||
statEmptyState: {
|
||||
marginTop: 14,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
statEmptyText: {
|
||||
fontSize: 13,
|
||||
color: '#7C85A3',
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 14,
|
||||
color: '#8087A2',
|
||||
fontWeight: '600',
|
||||
marginTop: 18,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
historyCard: {
|
||||
marginTop: 12,
|
||||
marginHorizontal: 16,
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 18,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 26,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#5460E54D',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
cardIconWrapper: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 23,
|
||||
backgroundColor: '#EEF0FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 14,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
cardTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1F2355',
|
||||
flexShrink: 1,
|
||||
},
|
||||
intensityBadge: {
|
||||
marginLeft: 8,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
},
|
||||
intensityText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
cardSubtitle: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: '#6B7693',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
color: '#596182',
|
||||
},
|
||||
emptyContainer: {
|
||||
marginTop: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
gap: 12,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#596182',
|
||||
},
|
||||
emptySubText: {
|
||||
fontSize: 13,
|
||||
color: '#8F96AF',
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#5C55FF',
|
||||
},
|
||||
retryText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
351
app/workout/notification-settings.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
getWorkoutNotificationPreferences,
|
||||
resetWorkoutNotificationPreferences,
|
||||
saveWorkoutNotificationPreferences,
|
||||
WorkoutNotificationPreferences
|
||||
} from '@/utils/workoutPreferences';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const WORKOUT_TYPES = [
|
||||
{ key: 'running', label: '跑步' },
|
||||
{ key: 'cycling', label: '骑行' },
|
||||
{ key: 'swimming', label: '游泳' },
|
||||
{ key: 'yoga', label: '瑜伽' },
|
||||
{ key: 'functionalstrengthtraining', label: '功能性力量训练' },
|
||||
{ key: 'traditionalstrengthtraining', label: '传统力量训练' },
|
||||
{ key: 'highintensityintervaltraining', label: '高强度间歇训练' },
|
||||
{ key: 'walking', label: '步行' },
|
||||
{ key: 'other', label: '其他运动' },
|
||||
];
|
||||
const WORKOUT_TYPE_KEYS = WORKOUT_TYPES.map(type => type.key);
|
||||
|
||||
export default function WorkoutNotificationSettingsScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const [preferences, setPreferences] = useState<WorkoutNotificationPreferences>({
|
||||
enabled: true,
|
||||
startTimeHour: 8,
|
||||
endTimeHour: 22,
|
||||
enabledWorkoutTypes: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, []);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const prefs = await getWorkoutNotificationPreferences();
|
||||
setPreferences(prefs);
|
||||
} catch (error) {
|
||||
console.error('加载偏好设置失败:', error);
|
||||
Alert.alert('错误', '加载设置失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = async (newPreferences: Partial<WorkoutNotificationPreferences>) => {
|
||||
try {
|
||||
await saveWorkoutNotificationPreferences(newPreferences);
|
||||
setPreferences(prev => ({ ...prev, ...newPreferences }));
|
||||
} catch (error) {
|
||||
console.error('保存偏好设置失败:', error);
|
||||
Alert.alert('错误', '保存设置失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnabledToggle = (enabled: boolean) => {
|
||||
savePreferences({ enabled });
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (type: 'start' | 'end', hour: number) => {
|
||||
if (type === 'start') {
|
||||
savePreferences({ startTimeHour: hour });
|
||||
} else {
|
||||
savePreferences({ endTimeHour: hour });
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkoutTypeToggle = (workoutType: string) => {
|
||||
const currentTypes = preferences.enabledWorkoutTypes.length === 0
|
||||
? [...WORKOUT_TYPE_KEYS] // 空数组表示全部启用,先展开成完整列表,避免影响其他开关的当前状态
|
||||
: [...preferences.enabledWorkoutTypes];
|
||||
|
||||
const nextTypes = currentTypes.includes(workoutType)
|
||||
? currentTypes.filter(type => type !== workoutType)
|
||||
: [...currentTypes, workoutType];
|
||||
|
||||
// 如果全部类型都开启,回退为空数组表示“全部启用”,以保持原有存储约定
|
||||
const normalizedTypes = nextTypes.length === WORKOUT_TYPE_KEYS.length ? [] : nextTypes;
|
||||
|
||||
savePreferences({ enabledWorkoutTypes: normalizedTypes });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
Alert.alert(
|
||||
'重置设置',
|
||||
'确定要重置所有锻炼通知设置为默认值吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '重置',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await resetWorkoutNotificationPreferences();
|
||||
await loadPreferences();
|
||||
Alert.alert('成功', '设置已重置为默认值');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '重置设置失败');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const formatHour = (hour: number) => {
|
||||
return `${hour.toString().padStart(2, '0')}:00`;
|
||||
};
|
||||
|
||||
const TimeSelector = ({
|
||||
label,
|
||||
value,
|
||||
onValueChange
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onValueChange: (hour: number) => void;
|
||||
}) => (
|
||||
<View style={styles.timeSelector}>
|
||||
<Text style={styles.timeLabel}>{label}</Text>
|
||||
<View style={styles.timeButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.timeButton, value === 0 && styles.timeButtonDisabled]}
|
||||
onPress={() => onValueChange(Math.max(0, value - 1))}
|
||||
disabled={value === 0}
|
||||
>
|
||||
<Text style={styles.timeButtonText}>-</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.timeValue}>{formatHour(value)}</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.timeButton, value === 23 && styles.timeButtonDisabled]}
|
||||
onPress={() => onValueChange(Math.min(23, value + 1))}
|
||||
disabled={value === 23}
|
||||
>
|
||||
<Text style={styles.timeButtonText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}}></View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text>加载中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false} contentContainerStyle={
|
||||
{
|
||||
paddingTop: safeAreaTop
|
||||
}
|
||||
}>
|
||||
{/* 主开关 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.settingItem}>
|
||||
<Text style={styles.settingLabel}>锻炼完成通知</Text>
|
||||
<Switch
|
||||
value={preferences.enabled}
|
||||
onValueChange={handleEnabledToggle}
|
||||
trackColor={{ false: '#E5E5E5', true: '#4CAF50' }}
|
||||
thumbColor={preferences.enabled ? '#FFFFFF' : '#FFFFFF'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.settingDescription}>
|
||||
当您完成锻炼时,发送个性化的鼓励通知
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{preferences.enabled && (
|
||||
<>
|
||||
{/* 时间范围设置 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>通知时间范围</Text>
|
||||
<Text style={styles.sectionDescription}>
|
||||
只在指定时间段内发送通知,避免深夜打扰
|
||||
</Text>
|
||||
|
||||
<TimeSelector
|
||||
label="开始时间"
|
||||
value={preferences.startTimeHour}
|
||||
onValueChange={(hour) => handleTimeRangeChange('start', hour)}
|
||||
/>
|
||||
|
||||
<TimeSelector
|
||||
label="结束时间"
|
||||
value={preferences.endTimeHour}
|
||||
onValueChange={(hour) => handleTimeRangeChange('end', hour)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 锻炼类型设置 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>锻炼类型</Text>
|
||||
<Text style={styles.sectionDescription}>
|
||||
选择要接收通知的锻炼类型,不选择表示接收所有类型
|
||||
</Text>
|
||||
|
||||
{WORKOUT_TYPES.map((type) => (
|
||||
<View key={type.key} style={styles.settingItem}>
|
||||
<Text style={styles.settingLabel}>{type.label}</Text>
|
||||
<Switch
|
||||
value={
|
||||
preferences.enabledWorkoutTypes.length === 0 ||
|
||||
preferences.enabledWorkoutTypes.includes(type.key)
|
||||
}
|
||||
onValueChange={() => handleWorkoutTypeToggle(type.key)}
|
||||
trackColor={{ false: '#E5E5E5', true: '#4CAF50' }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity style={styles.resetButton} onPress={handleReset}>
|
||||
<Text style={styles.resetButtonText}>重置为默认设置</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#1A1A1A',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
marginBottom: 16,
|
||||
lineHeight: 20,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
settingLabel: {
|
||||
fontSize: 16,
|
||||
color: '#1A1A1A',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666666',
|
||||
marginTop: -8,
|
||||
marginBottom: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
timeSelector: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
timeLabel: {
|
||||
fontSize: 16,
|
||||
color: '#1A1A1A',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timeButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
timeButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#4CAF50',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
timeButtonDisabled: {
|
||||
backgroundColor: '#E5E5E5',
|
||||
},
|
||||
timeButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
timeValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1A1A1A',
|
||||
minWidth: 60,
|
||||
textAlign: 'center',
|
||||
},
|
||||
resetButton: {
|
||||
backgroundColor: '#F44336',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
resetButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
BIN
assets/fonts/ali-bold.ttf
Normal file
BIN
assets/fonts/ali-regular.ttf
Normal file
BIN
assets/images/icons/icon-fitness.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/images/icons/icon-yingyang.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/images/medicine/image-medicine.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
assets/images/onboarding/challange.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
assets/images/onboarding/fasting.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/images/onboarding/medicine.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
assets/images/onboarding/statistic.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
assets/logo.png
Normal file
|
After Width: | Height: | Size: 672 KiB |
1
assets/lottie/loading-blue.json
Normal file
BIN
assets/machine.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -2,6 +2,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
@@ -12,6 +13,7 @@ const ActivityHeatMap = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const activityData = useAppSelector(stat => stat.user.activityHistory);
|
||||
|
||||
@@ -103,8 +105,20 @@ const ActivityHeatMap = () => {
|
||||
|
||||
// 获取月份标签(简化的月份标签系统)
|
||||
const getMonthLabels = useMemo(() => {
|
||||
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const monthNames = [
|
||||
t('statistics.activityHeatMap.months.1'),
|
||||
t('statistics.activityHeatMap.months.2'),
|
||||
t('statistics.activityHeatMap.months.3'),
|
||||
t('statistics.activityHeatMap.months.4'),
|
||||
t('statistics.activityHeatMap.months.5'),
|
||||
t('statistics.activityHeatMap.months.6'),
|
||||
t('statistics.activityHeatMap.months.7'),
|
||||
t('statistics.activityHeatMap.months.8'),
|
||||
t('statistics.activityHeatMap.months.9'),
|
||||
t('statistics.activityHeatMap.months.10'),
|
||||
t('statistics.activityHeatMap.months.11'),
|
||||
t('statistics.activityHeatMap.months.12'),
|
||||
];
|
||||
|
||||
// 简单策略:均匀分布4-5个月份标签
|
||||
const totalWeeks = weeksToShow;
|
||||
@@ -130,7 +144,7 @@ const ActivityHeatMap = () => {
|
||||
});
|
||||
|
||||
return labelPositions;
|
||||
}, [organizeDataByWeeks, weeksToShow]);
|
||||
}, [organizeDataByWeeks, weeksToShow, t]);
|
||||
|
||||
// 计算活动统计
|
||||
const activityStats = useMemo(() => {
|
||||
@@ -156,14 +170,14 @@ const ActivityHeatMap = () => {
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||
最近6个月活跃 {activityStats.activeDays} 天
|
||||
{t('statistics.activityHeatMap.subtitle', { days: activityStats.activeDays })}
|
||||
</Text>
|
||||
<View style={styles.rightSection}>
|
||||
<View style={[styles.statsBadge, {
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.1)'
|
||||
}]}>
|
||||
<Text style={[styles.statsText, { color: colors.primary }]}>
|
||||
{activityStats.activeRate}%
|
||||
{t('statistics.activityHeatMap.activeRate', { rate: activityStats.activeRate })}
|
||||
</Text>
|
||||
</View>
|
||||
<Popover
|
||||
@@ -184,23 +198,23 @@ const ActivityHeatMap = () => {
|
||||
>
|
||||
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
||||
能量值的积攒后续可以用来兑换 AI 相关权益
|
||||
{t('statistics.activityHeatMap.popover.title')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
||||
获取说明
|
||||
{t('statistics.activityHeatMap.popover.subtitle')}
|
||||
</Text>
|
||||
<View style={styles.popoverList}>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
1. 每日登录获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.login')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
2. 每日记录心情获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.mood')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
3. 记饮食获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.diet')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
4. 完成一次目标获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.goal')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -263,7 +277,9 @@ const ActivityHeatMap = () => {
|
||||
|
||||
{/* 图例 */}
|
||||
<View style={styles.legend}>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>少</Text>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>
|
||||
{t('statistics.activityHeatMap.legend.less')}
|
||||
</Text>
|
||||
<View style={styles.legendColors}>
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<View
|
||||
@@ -278,7 +294,9 @@ const ActivityHeatMap = () => {
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>多</Text>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>
|
||||
{t('statistics.activityHeatMap.legend.more')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface BasalMetabolismCardProps {
|
||||
@@ -14,6 +15,7 @@ interface BasalMetabolismCardProps {
|
||||
}
|
||||
|
||||
export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [basalMetabolism, setBasalMetabolism] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -90,7 +92,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('BasalMetabolismCard: 获取基础代谢数据失败:', error);
|
||||
console.error('BasalMetabolismCard: Failed to get basal metabolism data:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// 清理请求记录
|
||||
@@ -134,20 +136,20 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
// 使用 useMemo 优化状态描述计算
|
||||
const status = useMemo(() => {
|
||||
if (basalMetabolism === null || basalMetabolism === 0) {
|
||||
return { text: '未知', color: '#9AA3AE' };
|
||||
return { text: t('statistics.components.metabolism.status.unknown'), color: '#9AA3AE' };
|
||||
}
|
||||
|
||||
// 基于常见的基础代谢范围来判断状态
|
||||
if (basalMetabolism >= 1800) {
|
||||
return { text: '高代谢', color: '#10B981' };
|
||||
return { text: t('statistics.components.metabolism.status.high'), color: '#10B981' };
|
||||
} else if (basalMetabolism >= 1400) {
|
||||
return { text: '正常', color: '#3B82F6' };
|
||||
return { text: t('statistics.components.metabolism.status.normal'), color: '#3B82F6' };
|
||||
} else if (basalMetabolism >= 1000) {
|
||||
return { text: '偏低', color: '#F59E0B' };
|
||||
return { text: t('statistics.components.metabolism.status.low'), color: '#F59E0B' };
|
||||
} else {
|
||||
return { text: '较低', color: '#EF4444' };
|
||||
return { text: t('statistics.components.metabolism.status.veryLow'), color: '#EF4444' };
|
||||
}
|
||||
}, [basalMetabolism]);
|
||||
}, [basalMetabolism, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -163,7 +165,7 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
source={require('@/assets/images/icons/icon-fire.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>基础代谢</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.metabolism.title')}</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, { backgroundColor: status.color + '20' }]}>
|
||||
<Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>
|
||||
@@ -173,9 +175,9 @@ export function BasalMetabolismCard({ selectedDate, style }: BasalMetabolismCard
|
||||
{/* 数值显示区域 */}
|
||||
<View style={styles.valueSection}>
|
||||
<Text style={styles.value}>
|
||||
{loading ? '加载中...' : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||
{loading ? t('statistics.components.metabolism.loading') : (basalMetabolism != null && basalMetabolism > 0 ? Math.round(basalMetabolism).toString() : '--')}
|
||||
</Text>
|
||||
<Text style={styles.unit}>千卡/日</Text>
|
||||
<Text style={styles.unit}>{t('statistics.components.metabolism.unit')}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
|
||||
@@ -2,16 +2,17 @@ import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/d
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Animated, Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
export interface DateSelectorProps {
|
||||
@@ -54,10 +55,24 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
||||
const selectedIndex = externalSelectedIndex ?? internalSelectedIndex;
|
||||
|
||||
// Liquid Glass 可用性检查
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取日期数据
|
||||
const days = getMonthDaysZh(currentMonth);
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||
|
||||
// 判断当前选中的日期是否是今天
|
||||
const isSelectedDateToday = () => {
|
||||
const today = dayjs();
|
||||
const selectedDate = days[selectedIndex]?.date;
|
||||
|
||||
if (!selectedDate) return false;
|
||||
|
||||
// 检查是否是同一天且在同一个月
|
||||
return selectedDate.isSame(today, 'day') && currentMonth.isSame(today, 'month');
|
||||
};
|
||||
|
||||
// 滚动相关
|
||||
const daysScrollRef = useRef<ScrollView | null>(null);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
@@ -68,6 +83,9 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 滚动到指定索引
|
||||
const scrollToIndex = (index: number, animated = true) => {
|
||||
if (!daysScrollRef.current || scrollWidth === 0) return;
|
||||
@@ -102,7 +120,14 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
if (scrollWidth > 0 && autoScrollToSelected) {
|
||||
scrollToIndex(selectedIndex, true);
|
||||
}
|
||||
}, [scrollWidth, selectedIndex, autoScrollToSelected]);
|
||||
|
||||
// 淡入动画
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [scrollWidth, selectedIndex, autoScrollToSelected, fadeAnim]);
|
||||
|
||||
// 当选中索引变化时,滚动到对应位置
|
||||
useEffect(() => {
|
||||
@@ -191,23 +216,75 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToday = () => {
|
||||
const today = dayjs();
|
||||
setCurrentMonth(today);
|
||||
const todayDays = getMonthDaysZh(today);
|
||||
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
|
||||
|
||||
if (newSelectedIndex !== -1) {
|
||||
if (externalSelectedIndex === undefined) {
|
||||
setInternalSelectedIndex(newSelectedIndex);
|
||||
}
|
||||
const todayDate = today.toDate();
|
||||
setPickerDate(todayDate);
|
||||
onDateSelect?.(newSelectedIndex, todayDate);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{showMonthTitle && (
|
||||
<View style={styles.monthTitleContainer}>
|
||||
<Text style={styles.monthTitle}>{monthTitle}</Text>
|
||||
<View style={styles.monthActions}>
|
||||
{!isSelectedDateToday() && (
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToday}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.todayButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(124, 58, 237, 0.08)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.todayButton, styles.todayButtonFallback]}>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{showCalendarIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={openDatePicker}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.calendarIconButton}
|
||||
activeOpacity={0.7}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.calendarIconButton, styles.calendarIconFallback]}>
|
||||
<Ionicons name="calendar-outline" size={14} color="#666666" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Animated.View style={{ opacity: fadeAnim }}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -222,35 +299,64 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
|
||||
return (
|
||||
<View key={`${d.dayOfMonth}`} style={[styles.dayItemWrapper, dayItemStyle]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.dayPill,
|
||||
selected ? styles.dayPillSelected : styles.dayPillNormal,
|
||||
isFutureDate && styles.dayPillDisabled
|
||||
]}
|
||||
<Pressable
|
||||
onPress={() => !isFutureDate && handleDateSelect(i)}
|
||||
activeOpacity={isFutureDate ? 1 : 0.8}
|
||||
disabled={isFutureDate}
|
||||
style={({ pressed }) => [
|
||||
!isFutureDate && pressed && styles.dayPillPressed
|
||||
]}
|
||||
>
|
||||
{selected && !isFutureDate ? (
|
||||
isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.dayPill}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Text style={styles.dayLabelSelected}>
|
||||
{d.weekdayZh}
|
||||
</Text>
|
||||
<Text style={styles.dayDateSelected}>
|
||||
{d.dayOfMonth}
|
||||
</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.dayPill, styles.dayPillSelectedFallback]}>
|
||||
<Text style={styles.dayLabelSelected}>
|
||||
{d.weekdayZh}
|
||||
</Text>
|
||||
<Text style={styles.dayDateSelected}>
|
||||
{d.dayOfMonth}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View style={[
|
||||
styles.dayPill,
|
||||
styles.dayPillNormal,
|
||||
isFutureDate && styles.dayPillDisabled
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.dayLabel,
|
||||
selected && styles.dayLabelSelected,
|
||||
isFutureDate && styles.dayLabelDisabled
|
||||
]}>
|
||||
{d.weekdayZh}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.dayDate,
|
||||
selected && styles.dayDateSelected,
|
||||
isFutureDate && styles.dayDateDisabled
|
||||
]}>
|
||||
{d.dayOfMonth}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
|
||||
{/* 日历选择弹窗 */}
|
||||
<Modal
|
||||
@@ -260,6 +366,46 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
onRequestClose={closeDatePicker}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.modalSheet}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.7)"
|
||||
isInteractive={false}
|
||||
>
|
||||
<DateTimePicker
|
||||
value={pickerDate}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||
maximumDate={disableFutureDates ? new Date() : undefined}
|
||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
} else {
|
||||
if (event.type === 'set' && date) {
|
||||
onConfirmDate(date);
|
||||
} else {
|
||||
closeDatePicker();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.modalSheet}>
|
||||
<DateTimePicker
|
||||
value={pickerDate}
|
||||
@@ -282,17 +428,18 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
@@ -305,18 +452,47 @@ const styles = StyleSheet.create({
|
||||
monthTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
monthActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 20,
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
color: '#1a1a1a',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
calendarIconButton: {
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
marginLeft: 4
|
||||
marginLeft: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
calendarIconFallback: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
todayButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
marginRight: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
todayButtonFallback: {
|
||||
backgroundColor: '#EEF2FF',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124, 58, 237, 0.2)',
|
||||
},
|
||||
todayButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#7c3aed',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
daysContainer: {
|
||||
paddingBottom: 8,
|
||||
@@ -332,17 +508,24 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
dayPillNormal: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
dayPillSelected: {
|
||||
dayPillPressed: {
|
||||
opacity: 0.8,
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
dayPillSelectedFallback: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
dayPillDisabled: {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -351,39 +534,47 @@ const styles = StyleSheet.create({
|
||||
dayLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: 'gray',
|
||||
color: '#8e8e93',
|
||||
marginBottom: 2,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
dayLabelSelected: {
|
||||
color: '#192126',
|
||||
color: '#1a1a1a',
|
||||
fontWeight: '800',
|
||||
},
|
||||
dayLabelDisabled: {
|
||||
color: 'gray',
|
||||
color: '#c7c7cc',
|
||||
},
|
||||
dayDate: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: 'gray',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#8e8e93',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
dayDateSelected: {
|
||||
color: '#192126',
|
||||
color: '#1a1a1a',
|
||||
fontWeight: '800',
|
||||
},
|
||||
dayDateDisabled: {
|
||||
color: 'gray',
|
||||
color: '#c7c7cc',
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
},
|
||||
modalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
padding: 20,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
@@ -392,20 +583,35 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
},
|
||||
modalBtn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#F1F5F9',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f8fafc',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalBtnPrimary: {
|
||||
backgroundColor: '#7a5af8',
|
||||
backgroundColor: '#7c3aed',
|
||||
borderWidth: 1,
|
||||
borderColor: '#7c3aed',
|
||||
shadowColor: '#7c3aed',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
modalBtnText: {
|
||||
color: '#334155',
|
||||
color: '#475569',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
modalBtnTextPrimary: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import { GoalListItem } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React, { useRef } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
interface GoalCardProps {
|
||||
goal: GoalListItem;
|
||||
onPress?: (goal: GoalListItem) => void;
|
||||
onDelete?: (goalId: string) => void;
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
export const GoalCard: React.FC<GoalCardProps> = ({
|
||||
goal,
|
||||
onPress,
|
||||
onDelete,
|
||||
showStatus = true
|
||||
}) => {
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
|
||||
// 获取重复类型显示文本
|
||||
const getRepeatTypeText = (goal: GoalListItem) => {
|
||||
switch (goal.repeatType) {
|
||||
case 'daily':
|
||||
return '每日';
|
||||
case 'weekly':
|
||||
return '每周';
|
||||
case 'monthly':
|
||||
return '每月';
|
||||
default:
|
||||
return '每日';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取目标状态显示文本
|
||||
const getStatusText = (goal: GoalListItem) => {
|
||||
switch (goal.status) {
|
||||
case 'active':
|
||||
return '进行中';
|
||||
case 'paused':
|
||||
return '已暂停';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
default:
|
||||
return '进行中';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取目标状态颜色
|
||||
const getStatusColor = (goal: GoalListItem) => {
|
||||
switch (goal.status) {
|
||||
case 'active':
|
||||
return '#10B981';
|
||||
case 'paused':
|
||||
return '#F59E0B';
|
||||
case 'completed':
|
||||
return '#3B82F6';
|
||||
case 'cancelled':
|
||||
return '#EF4444';
|
||||
default:
|
||||
return '#10B981';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取目标图标
|
||||
const getGoalIcon = (goal: GoalListItem) => {
|
||||
// 根据目标类别或标题返回不同的图标
|
||||
const title = goal.title.toLowerCase();
|
||||
const category = goal.category?.toLowerCase();
|
||||
|
||||
if (title.includes('运动') || title.includes('健身') || title.includes('跑步')) {
|
||||
return 'fitness-center';
|
||||
} else if (title.includes('喝水') || title.includes('饮水')) {
|
||||
return 'local-drink';
|
||||
} else if (title.includes('睡眠') || title.includes('睡觉')) {
|
||||
return 'bedtime';
|
||||
} else if (title.includes('学习') || title.includes('读书')) {
|
||||
return 'school';
|
||||
} else if (title.includes('冥想') || title.includes('放松')) {
|
||||
return 'self-improvement';
|
||||
} else if (title.includes('早餐') || title.includes('午餐') || title.includes('晚餐')) {
|
||||
return 'restaurant';
|
||||
} else {
|
||||
return 'flag';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除操作
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
`确定要删除目标"${goal.title}"吗?此操作无法撤销。`,
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete?.(goal.id);
|
||||
swipeableRef.current?.close();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染删除按钮
|
||||
const renderRightActions = () => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons style={{
|
||||
marginBottom: 10
|
||||
}} name="delete" size={24} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
ref={swipeableRef}
|
||||
renderRightActions={renderRightActions}
|
||||
rightThreshold={40}
|
||||
overshootRight={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.goalCard}
|
||||
onPress={() => onPress?.(goal)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* 左侧图标 */}
|
||||
<View style={styles.goalIcon}>
|
||||
<MaterialIcons name={getGoalIcon(goal)} size={20} color="#7A5AF8" />
|
||||
<View style={styles.iconStars}>
|
||||
<View style={styles.star} />
|
||||
<View style={styles.star} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 中间内容 */}
|
||||
<View style={styles.goalContent}>
|
||||
<Text style={styles.goalTitle} numberOfLines={1}>
|
||||
{goal.title}
|
||||
</Text>
|
||||
|
||||
{/* 底部信息行 */}
|
||||
<View style={styles.goalInfo}>
|
||||
{/* 积分 */}
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoText}>+1</Text>
|
||||
</View>
|
||||
|
||||
{/* 目标数量 */}
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoText}>
|
||||
{goal.targetCount || goal.frequency}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 提醒图标(如果有提醒) */}
|
||||
{goal.hasReminder && (
|
||||
<View style={styles.infoItem}>
|
||||
<MaterialIcons name="notifications" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 提醒时间(如果有提醒) */}
|
||||
{goal.hasReminder && goal.reminderTime && (
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoText}>{goal.reminderTime}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 重复图标 */}
|
||||
<View style={styles.infoItem}>
|
||||
<MaterialIcons name="loop" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
|
||||
{/* 重复类型 */}
|
||||
<View style={styles.infoItem}>
|
||||
<Text style={styles.infoText}>{getRepeatTypeText(goal)}</Text>
|
||||
</View>
|
||||
|
||||
{/* 结束日期 */}
|
||||
{goal.endDate && (
|
||||
<View style={styles.infoItem}>
|
||||
<MaterialIcons
|
||||
name="calendar-month"
|
||||
size={12}
|
||||
color="#9CA3AF"
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text style={styles.infoText}>{goal.endDate}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧状态指示器 */}
|
||||
{showStatus && (
|
||||
<View style={[styles.statusIndicator, { backgroundColor: getStatusColor(goal) }]}>
|
||||
<Text style={styles.statusText}>{getStatusText(goal)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Swipeable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
goalCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
goalIcon: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
iconStars: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
},
|
||||
star: {
|
||||
width: 4,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
goalContent: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
goalTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1F2937',
|
||||
marginBottom: 8,
|
||||
},
|
||||
goalInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
infoItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
statusIndicator: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
minWidth: 60,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
deleteButton: {
|
||||
width: 60,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import React, { useRef } from 'react';
|
||||
import { Dimensions, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { GoalCard, GoalItem } from './GoalCard';
|
||||
|
||||
interface GoalCarouselProps {
|
||||
goals: GoalItem[];
|
||||
onGoalPress?: (item: GoalItem) => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
export function GoalCarousel({ goals, onGoalPress }: GoalCarouselProps) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
if (!goals || goals.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={[styles.emptyText, { color: colorTokens.textMuted }]}>
|
||||
今天暂无目标
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={(screenWidth - 60) * 0.65 + 16} // 卡片宽度 + 间距
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
{goals.map((item, index) => (
|
||||
<GoalCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onPress={onGoalPress}
|
||||
/>
|
||||
))}
|
||||
{/* 占位符,确保最后一张卡片有足够的滑动空间 */}
|
||||
<View style={{ width: 20 }} />
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部指示器 */}
|
||||
{/* <View style={styles.indicatorContainer}>
|
||||
{goals.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.indicator,
|
||||
{ backgroundColor: index === 0 ? colorTokens.primary : colorTokens.border }
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View> */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
},
|
||||
scrollView: {
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
height: 140,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: 20,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
indicatorContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
indicator: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
});
|
||||
@@ -1,457 +0,0 @@
|
||||
import { TaskListItem } from '@/types/goals';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface GoalsPageGuideProps {
|
||||
visible: boolean;
|
||||
onComplete: () => void;
|
||||
tasks?: TaskListItem[]; // 添加任务数据,用于智能引导
|
||||
}
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
// 计算精确的高亮位置
|
||||
const calculateHighlightPosition = (stepIndex: number, hasTasks: boolean) => {
|
||||
const baseTop = 120; // 状态栏 + 标题区域高度
|
||||
const cardHeight = 180; // 任务进度卡片高度
|
||||
const filterHeight = 60; // 筛选标签高度
|
||||
const listHeight = 300; // 任务列表高度
|
||||
|
||||
switch (stepIndex) {
|
||||
case 0: // 欢迎标题
|
||||
return {
|
||||
top: baseTop - 40,
|
||||
left: 20,
|
||||
right: 20,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
};
|
||||
case 1: // 任务进度卡片
|
||||
return {
|
||||
top: baseTop + 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
height: cardHeight,
|
||||
borderRadius: 16,
|
||||
};
|
||||
case 2: // 目标管理按钮(有任务时)
|
||||
if (hasTasks) {
|
||||
return {
|
||||
top: baseTop + 40,
|
||||
right: 60,
|
||||
width: 40,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
top: baseTop + 40,
|
||||
right: 20,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
};
|
||||
}
|
||||
case 3: // 创建新目标按钮(有任务时)
|
||||
if (hasTasks) {
|
||||
return {
|
||||
top: baseTop + 40,
|
||||
right: 20,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
};
|
||||
} else {
|
||||
return null; // 没有这一步
|
||||
}
|
||||
case 4: // 任务筛选标签
|
||||
return {
|
||||
top: baseTop + cardHeight + 40,
|
||||
left: 20,
|
||||
right: 20,
|
||||
height: filterHeight,
|
||||
borderRadius: 24,
|
||||
};
|
||||
case 5: // 任务列表
|
||||
return {
|
||||
top: baseTop + cardHeight + filterHeight + 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
height: listHeight,
|
||||
borderRadius: 24,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const GoalsPageGuide: React.FC<GoalsPageGuideProps> = ({
|
||||
visible,
|
||||
onComplete,
|
||||
tasks = [],
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(0.8)).current;
|
||||
|
||||
// 根据任务数据智能生成引导步骤
|
||||
const generateSteps = () => {
|
||||
const hasTasks = tasks.length > 0;
|
||||
const hasCompletedTasks = tasks.some(task => task.status === 'completed');
|
||||
const hasPendingTasks = tasks.some(task => task.status === 'pending');
|
||||
|
||||
const baseSteps = [
|
||||
{
|
||||
title: '欢迎来到目标页面',
|
||||
description: '这里是您的目标管理中心,让我们一起来了解各个功能。',
|
||||
icon: 'flag',
|
||||
},
|
||||
{
|
||||
title: '任务进度统计',
|
||||
description: '这里显示您当天的任务完成情况,包括待完成、已完成和已跳过的任务数量。',
|
||||
icon: 'analytics',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据任务状态添加不同的引导内容
|
||||
if (!hasTasks) {
|
||||
baseSteps.push({
|
||||
title: '创建您的第一个目标',
|
||||
description: '点击加号按钮,创建您的第一个目标,系统会自动生成相应的任务。',
|
||||
icon: 'add',
|
||||
});
|
||||
} else {
|
||||
baseSteps.push(
|
||||
{
|
||||
title: '目标管理',
|
||||
description: '点击右上角的目标按钮,可以查看和管理您的所有目标。',
|
||||
icon: 'flag',
|
||||
},
|
||||
{
|
||||
title: '创建新目标',
|
||||
description: '点击加号按钮,可以快速创建新的目标。',
|
||||
icon: 'add',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
baseSteps.push({
|
||||
title: '任务筛选',
|
||||
description: '使用这些标签可以筛选查看不同状态的任务。',
|
||||
icon: 'filter-list',
|
||||
});
|
||||
|
||||
// 根据任务状态调整任务列表的引导内容
|
||||
if (!hasTasks) {
|
||||
baseSteps.push({
|
||||
title: '任务列表',
|
||||
description: '创建目标后,您的任务将显示在这里。',
|
||||
icon: 'list',
|
||||
});
|
||||
} else if (!hasPendingTasks && hasCompletedTasks) {
|
||||
baseSteps.push({
|
||||
title: '任务列表',
|
||||
description: '您已完成所有任务!可以创建新目标或查看历史记录。',
|
||||
icon: 'check-circle',
|
||||
});
|
||||
} else {
|
||||
baseSteps.push({
|
||||
title: '任务列表',
|
||||
description: '这里显示您的所有任务,可以标记完成或跳过。',
|
||||
icon: 'list',
|
||||
});
|
||||
}
|
||||
|
||||
return baseSteps;
|
||||
};
|
||||
|
||||
const steps = generateSteps();
|
||||
const hasTasks = tasks.length > 0;
|
||||
const currentHighlightPosition = calculateHighlightPosition(currentStep, hasTasks);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, fadeAnim, scaleAnim]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
handleComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setCurrentStep(0);
|
||||
onComplete();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
handleComplete();
|
||||
};
|
||||
|
||||
if (!visible || !currentHighlightPosition) return null;
|
||||
|
||||
const currentStepData = steps[currentStep];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
>
|
||||
<StatusBar backgroundColor="rgba(0, 0, 0, 0.8)" barStyle="light-content" />
|
||||
|
||||
{/* 背景遮罩 */}
|
||||
<View style={styles.overlay}>
|
||||
{/* 高亮区域 */}
|
||||
<View style={[styles.highlightArea, currentHighlightPosition]}>
|
||||
<View style={[styles.highlightBorder, { borderRadius: currentHighlightPosition.borderRadius }]} />
|
||||
</View>
|
||||
|
||||
{/* 引导内容 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.guideContainer,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 步骤指示器 */}
|
||||
<View style={styles.stepIndicator}>
|
||||
{steps.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.stepDot,
|
||||
index === currentStep && styles.stepDotActive,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 图标 */}
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialIcons
|
||||
name={currentStepData.icon as any}
|
||||
size={48}
|
||||
color="#7A5AF8"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.title}>{currentStepData.title}</Text>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text style={styles.description}>{currentStepData.description}</Text>
|
||||
|
||||
{/* 按钮区域 */}
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
|
||||
<Text style={styles.skipButtonText}>跳过</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.navigationButtons}>
|
||||
{currentStep > 0 && (
|
||||
<TouchableOpacity style={styles.previousButton} onPress={handlePrevious}>
|
||||
<Text style={styles.previousButtonText}>回顾</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.nextButton} onPress={handleNext}>
|
||||
<Text style={styles.nextButtonText}>
|
||||
{currentStep === steps.length - 1 ? '完成' : '下一步'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
highlightArea: {
|
||||
position: 'absolute',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
highlightBorder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderWidth: 2,
|
||||
borderColor: '#7A5AF8',
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.08)',
|
||||
shadowColor: '#7A5AF8',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
guideContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 120,
|
||||
left: 20,
|
||||
right: 20,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
},
|
||||
stepIndicator: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
},
|
||||
stepDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#E5E7EB',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
stepDotActive: {
|
||||
backgroundColor: '#7A5AF8',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#F3F4F6',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#1F2937',
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
navigationButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
skipButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
skipButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
},
|
||||
previousButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#7A5AF8',
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.1)',
|
||||
},
|
||||
previousButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#7A5AF8',
|
||||
},
|
||||
nextButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#7A5AF8',
|
||||
},
|
||||
nextButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
|
||||
import dayjs from 'dayjs';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MoodCardProps {
|
||||
@@ -11,6 +12,7 @@ interface MoodCardProps {
|
||||
}
|
||||
|
||||
export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null;
|
||||
const animationRef = useRef<LottieView>(null);
|
||||
|
||||
@@ -28,7 +30,7 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
source={require('@/assets/images/icons/icon-mood.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>心情</Text>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.mood.title')}</Text>
|
||||
</View>
|
||||
<LottieView
|
||||
ref={animationRef}
|
||||
@@ -48,7 +50,7 @@ export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.moodEmptyText}>点击记录心情</Text>
|
||||
<Text style={styles.moodEmptyText}>{t('statistics.components.mood.empty')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useNotifications } from '../hooks/useNotifications';
|
||||
import { ThemedText } from './ThemedText';
|
||||
import { ThemedView } from './ThemedView';
|
||||
import { DailySummaryTest } from './DailySummaryTest';
|
||||
|
||||
export const NotificationTest: React.FC = () => {
|
||||
const {
|
||||
@@ -23,9 +21,7 @@ export const NotificationTest: React.FC = () => {
|
||||
cancelAllNotifications,
|
||||
getAllScheduledNotifications,
|
||||
sendWorkoutReminder,
|
||||
sendGoalAchievement,
|
||||
sendMoodCheckinReminder,
|
||||
debugNotificationStatus,
|
||||
} = useNotifications();
|
||||
|
||||
const [scheduledNotifications, setScheduledNotifications] = useState<any[]>([]);
|
||||
@@ -97,15 +93,6 @@ export const NotificationTest: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendGoalAchievement = async () => {
|
||||
try {
|
||||
await sendGoalAchievement('目标达成', '恭喜您完成了本周的运动目标!');
|
||||
Alert.alert('成功', '目标达成通知已发送');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '发送目标达成通知失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMoodCheckinReminder = async () => {
|
||||
try {
|
||||
await sendMoodCheckinReminder('心情打卡', '记得记录今天的心情状态哦');
|
||||
@@ -134,15 +121,6 @@ export const NotificationTest: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDebugNotificationStatus = async () => {
|
||||
try {
|
||||
await debugNotificationStatus();
|
||||
Alert.alert('调试完成', '请查看控制台输出');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '调试失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
@@ -193,13 +171,6 @@ export const NotificationTest: React.FC = () => {
|
||||
<ThemedText style={styles.buttonText}>发送运动提醒</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSendGoalAchievement}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>发送目标达成通知</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={handleSendMoodCheckinReminder}
|
||||
@@ -214,13 +185,6 @@ export const NotificationTest: React.FC = () => {
|
||||
<ThemedText style={styles.buttonText}>获取已安排通知</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.debugButton]}
|
||||
onPress={handleDebugNotificationStatus}
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>调试通知状态</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.dangerButton]}
|
||||
onPress={handleCancelAllNotifications}
|
||||
@@ -247,9 +211,6 @@ export const NotificationTest: React.FC = () => {
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 每日总结推送测试 */}
|
||||
<DailySummaryTest userName="测试用户" />
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AnimatedNumber } from '@/components/AnimatedNumber';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useActiveCalories } from '@/hooks/useActiveCalories';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useActiveCalories } from '@/hooks/useActiveCalories';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { fetchDailyBasalMetabolism, fetchDailyNutritionData, selectBasalMetabolismByDate, selectNutritionSummaryByDate } from '@/store/nutritionSlice';
|
||||
import { triggerLightHaptic } from '@/utils/haptics';
|
||||
@@ -9,6 +9,7 @@ import { calculateRemainingCalories } from '@/utils/nutrition';
|
||||
import dayjs from 'dayjs';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
|
||||
@@ -25,10 +26,12 @@ export type NutritionRadarCardProps = {
|
||||
// 简化的圆环进度组件
|
||||
const SimpleRingProgress = ({
|
||||
remainingCalories,
|
||||
totalAvailable
|
||||
totalAvailable,
|
||||
t
|
||||
}: {
|
||||
remainingCalories: number;
|
||||
totalAvailable: number;
|
||||
t: any;
|
||||
}) => {
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
const radius = 32;
|
||||
@@ -82,7 +85,7 @@ const SimpleRingProgress = ({
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}>
|
||||
{Math.round(remainingCalories)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>还能吃</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>{t('statistics.components.diet.remaining')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -93,9 +96,12 @@ export function NutritionRadarCard({
|
||||
style,
|
||||
resetToken,
|
||||
}: NutritionRadarCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -117,30 +123,31 @@ export function NutritionRadarCard({
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
|
||||
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
|
||||
]);
|
||||
|
||||
if (isLoggedIn) {
|
||||
await dispatch(fetchDailyNutritionData(targetDate)).unwrap()
|
||||
}
|
||||
await dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap()
|
||||
} catch (error) {
|
||||
console.error('NutritionRadarCard: 获取营养卡片数据失败:', error);
|
||||
console.error('NutritionRadarCard: Failed to get nutrition card data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNutritionCardData();
|
||||
}, [selectedDate, dispatch]);
|
||||
}, [selectedDate, dispatch, isLoggedIn]);
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
{ label: '热量', value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} 千卡` : '0 千卡', color: '#FF6B6B' },
|
||||
{ label: '蛋白质', value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||
{ label: '碳水', value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||
{ label: '脂肪', value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||
{ label: '纤维', value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||
{ label: '钠', value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||
{ label: t('statistics.components.diet.calories'), value: nutritionSummary ? `${Math.round(nutritionSummary.totalCalories)} ${t('statistics.components.diet.kcal')}` : `0 ${t('statistics.components.diet.kcal')}`, color: '#FF6B6B' },
|
||||
{ label: t('statistics.components.diet.protein'), value: nutritionSummary ? `${nutritionSummary.totalProtein.toFixed(1)} g` : '0.0 g', color: '#4ECDC4' },
|
||||
{ label: t('statistics.components.diet.carb'), value: nutritionSummary ? `${nutritionSummary.totalCarbohydrate.toFixed(1)} g` : '0.0 g', color: '#45B7D1' },
|
||||
{ label: t('statistics.components.diet.fat'), value: nutritionSummary ? `${nutritionSummary.totalFat.toFixed(1)} g` : '0.0 g', color: '#FFA07A' },
|
||||
{ label: t('statistics.components.diet.fiber'), value: nutritionSummary ? `${nutritionSummary.totalFiber.toFixed(1)} g` : '0.0 g', color: '#98D8C8' },
|
||||
{ label: t('statistics.components.diet.sodium'), value: nutritionSummary ? `${Math.round(nutritionSummary.totalSodium)} mg` : '0 mg', color: '#F7DC6F' },
|
||||
];
|
||||
}, [nutritionSummary]);
|
||||
}, [nutritionSummary, t]);
|
||||
|
||||
// 计算还能吃的卡路里
|
||||
const consumedCalories = nutritionSummary?.totalCalories || 0;
|
||||
@@ -168,10 +175,10 @@ export function NutritionRadarCard({
|
||||
source={require('@/assets/images/icons/icon-healthy-diet.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.cardTitle}>饮食分析</Text>
|
||||
<Text style={styles.cardTitle}>{t('statistics.components.diet.title')}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{loading ? '加载中...' : `更新: ${dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm')}`}
|
||||
{loading ? t('statistics.components.diet.loading') : t('statistics.components.diet.updated', { time: dayjs(nutritionSummary?.updatedAt).format('MM-DD HH:mm') })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -180,6 +187,7 @@ export function NutritionRadarCard({
|
||||
<SimpleRingProgress
|
||||
remainingCalories={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||
totalAvailable={(loading || activeCaloriesLoading) ? 0 : effectiveBasalMetabolism + effectiveActiveCalories}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -199,7 +207,7 @@ export function NutritionRadarCard({
|
||||
<View style={styles.calorieSection}>
|
||||
<View style={styles.calorieContent}>
|
||||
<View style={styles.calculationRow}>
|
||||
<Text style={styles.calorieSubtitle}>还能吃</Text>
|
||||
<Text style={styles.calorieSubtitle}>{t('statistics.components.diet.remaining')}</Text>
|
||||
<View style={styles.remainingCaloriesContainer}>
|
||||
<AnimatedNumber
|
||||
value={(loading || activeCaloriesLoading) ? 0 : remainingCalories}
|
||||
@@ -207,11 +215,11 @@ export function NutritionRadarCard({
|
||||
style={styles.mainValue}
|
||||
format={(v) => (loading || activeCaloriesLoading) ? '--' : Math.round(v).toString()}
|
||||
/>
|
||||
<Text style={styles.calorieUnit}>千卡</Text>
|
||||
<Text style={styles.calorieUnit}>{t('statistics.components.diet.kcal')}</Text>
|
||||
</View>
|
||||
<Text style={styles.calculationText}> = </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>基代</Text>
|
||||
<Text style={styles.calculationLabel}>{t('statistics.components.diet.basal')}</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={loading ? 0 : effectiveBasalMetabolism}
|
||||
@@ -221,7 +229,7 @@ export function NutritionRadarCard({
|
||||
/>
|
||||
<Text style={styles.calculationText}> + </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>运动</Text>
|
||||
<Text style={styles.calculationLabel}>{t('statistics.components.diet.exercise')}</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={activeCaloriesLoading ? 0 : effectiveActiveCalories}
|
||||
@@ -231,7 +239,7 @@ export function NutritionRadarCard({
|
||||
/>
|
||||
<Text style={styles.calculationText}> - </Text>
|
||||
<View style={styles.calculationItem}>
|
||||
<Text style={styles.calculationLabel}>饮食</Text>
|
||||
<Text style={styles.calculationLabel}>{t('statistics.components.diet.diet')}</Text>
|
||||
</View>
|
||||
<AnimatedNumber
|
||||
value={loading ? 0 : consumedCalories}
|
||||
@@ -250,7 +258,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionItem}
|
||||
onPress={() => {
|
||||
triggerLightHaptic();
|
||||
pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`);
|
||||
router.push(`${ROUTES.FOOD_CAMERA}?mealType=${currentMealType}`);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -260,7 +268,7 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>AI识别</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.aiRecognition')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -277,14 +285,14 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>食物库</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.foodLibrary')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.foodOptionItem}
|
||||
onPress={() => {
|
||||
triggerLightHaptic();
|
||||
pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
||||
router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -294,7 +302,24 @@ export function NutritionRadarCard({
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>一句话记录</Text>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.voiceRecord')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.foodOptionItem}
|
||||
onPress={() => {
|
||||
triggerLightHaptic();
|
||||
pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.foodOptionIcon]}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-yingyang.png')}
|
||||
style={styles.foodOptionImage}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.foodOptionText}>{t('statistics.components.diet.nutritionLabel')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -493,16 +518,17 @@ const styles = StyleSheet.create({
|
||||
// 食物选项样式
|
||||
foodOptionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#F1F5F9',
|
||||
gap: 16,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
foodOptionItem: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
foodOptionIcon: {
|
||||
width: 24,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
// 使用原生View来替代SVG,避免导入问题
|
||||
// import Svg, { Rect } from 'react-native-svg';
|
||||
@@ -28,6 +29,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
curDate,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const [stepCount, setStepCount] = useState(0)
|
||||
@@ -36,7 +38,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
|
||||
const getStepData = useCallback(async (date: Date) => {
|
||||
try {
|
||||
logger.info('获取步数数据...');
|
||||
logger.info('Getting step data...');
|
||||
|
||||
// 先获取步数,立即更新UI
|
||||
const [steps, hourly] = await Promise.all([
|
||||
@@ -47,7 +49,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
setHourSteps(hourly);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取步数数据失败:', error);
|
||||
logger.error('Failed to get step data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -122,7 +124,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
source={require('@/assets/images/icons/icon-step.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>步数</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.steps.title')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 柱状图 */}
|
||||
@@ -190,7 +192,7 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
<AnimatedNumber
|
||||
value={stepCount || 0}
|
||||
style={styles.stepCount}
|
||||
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
|
||||
format={(v) => stepCount !== null ? `${Math.round(v)}` : '--'}
|
||||
resetToken={stepCount}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fetchHRVWithStatus } from '@/utils/health';
|
||||
import { convertHrvToStressIndex } from '@/utils/stress';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { StressAnalysisModal } from './StressAnalysisModal';
|
||||
|
||||
@@ -10,20 +12,7 @@ interface StressMeterProps {
|
||||
}
|
||||
|
||||
export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
// 将HRV值转换为压力指数(0-100)
|
||||
// HRV值范围:30-110ms,映射到压力指数100-0
|
||||
// HRV值越高,压力越小;HRV值越低,压力越大
|
||||
const convertHrvToStressIndex = (hrv: number | null): number | null => {
|
||||
if (hrv === null || hrv === 0) return null;
|
||||
|
||||
// HRV 范围: 30-110ms,对应压力指数: 100-0
|
||||
// 线性映射: stressIndex = 100 - ((hrv - 30) / (110 - 30)) * 100
|
||||
const normalizedHrv = Math.max(30, Math.min(130, hrv));
|
||||
const stressIndex = 100 - ((normalizedHrv - 30) / (130 - 30)) * 100;
|
||||
|
||||
return Math.round(stressIndex);
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hrvValue, setHrvValue] = useState(0)
|
||||
|
||||
@@ -34,23 +23,23 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
const getHrvData = async () => {
|
||||
try {
|
||||
console.log('StressMeter: 开始获取HRV数据...', curDate);
|
||||
console.log('StressMeter: Starting to get HRV data...', curDate);
|
||||
|
||||
// 使用智能HRV数据获取功能
|
||||
const result = await fetchHRVWithStatus(curDate);
|
||||
|
||||
console.log('StressMeter: HRV数据获取结果:', result);
|
||||
console.log('StressMeter: HRV data fetch result:', result);
|
||||
|
||||
if (result.hrvData) {
|
||||
setHrvValue(Math.round(result.hrvData.value));
|
||||
console.log(`StressMeter: 使用${result.message},HRV值: ${result.hrvData.value}ms`);
|
||||
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
||||
} else {
|
||||
console.log('StressMeter: 未获取到HRV数据');
|
||||
console.log('StressMeter: No HRV data obtained');
|
||||
// 可以设置一个默认值或者显示无数据状态
|
||||
setHrvValue(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('StressMeter: 获取HRV数据失败:', error);
|
||||
console.error('StressMeter: Failed to get HRV data:', error);
|
||||
setHrvValue(0);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +73,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
source={require('@/assets/images/icons/icon-pressure.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>压力</Text>
|
||||
<Text style={styles.title}>{t('statistics.components.stress.title')}</Text>
|
||||
</View>
|
||||
{/* {updateTime && (
|
||||
<Text style={styles.headerUpdateTime}>{formatUpdateTime(updateTime)}</Text>
|
||||
@@ -94,7 +83,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
{/* 数值显示区域 */}
|
||||
<View style={styles.valueSection}>
|
||||
<Text style={styles.value}>{hrvValue || '--'}</Text>
|
||||
<Text>ms</Text>
|
||||
<Text>{t('statistics.components.stress.unit')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 进度条区域 */}
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { completeTask, skipTask } from '@/store/tasksSlice';
|
||||
import { TaskListItem } from '@/types/goals';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { Alert, Animated, Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskListItem;
|
||||
onTaskCompleted?: (task: TaskListItem) => void; // 任务完成回调
|
||||
}
|
||||
|
||||
export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
task,
|
||||
onTaskCompleted,
|
||||
}) => {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const { showConfirm } = useGlobalDialog();
|
||||
const router = useRouter();
|
||||
|
||||
// 创建进度条动画值
|
||||
const progressAnimation = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 当任务进度变化时,启动动画
|
||||
React.useEffect(() => {
|
||||
const targetProgress = task.progressPercentage > 0 ? Math.min(task.progressPercentage, 100) : 6;
|
||||
|
||||
Animated.timing(progressAnimation, {
|
||||
toValue: targetProgress,
|
||||
duration: 800, // 动画持续时间800毫秒
|
||||
useNativeDriver: false, // 因为我们要动画width属性,所以不能使用原生驱动
|
||||
}).start();
|
||||
}, [task.progressPercentage, progressAnimation]);
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'in_progress':
|
||||
return '进行中';
|
||||
case 'overdue':
|
||||
return '已过期';
|
||||
case 'skipped':
|
||||
return '已跳过';
|
||||
default:
|
||||
return '待开始';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'overdue':
|
||||
return '#EF4444'; // High - 过期任务
|
||||
case 'in_progress':
|
||||
return '#F59E0B'; // Medium - 进行中
|
||||
case 'completed':
|
||||
return '#10B981'; // Low - 已完成
|
||||
default:
|
||||
return '#6B7280'; // Default - 待开始
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'overdue':
|
||||
return '高';
|
||||
case 'in_progress':
|
||||
return '中';
|
||||
case 'completed':
|
||||
return '低';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const month = date.toLocaleDateString('zh-CN', { month: 'short' });
|
||||
const day = date.getDate();
|
||||
return `${day} ${month}`;
|
||||
};
|
||||
|
||||
const handleCompleteTask = async () => {
|
||||
// 如果任务已经完成,不执行任何操作
|
||||
if (task.status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用完成任务 API
|
||||
await dispatch(completeTask({
|
||||
taskId: task.id,
|
||||
completionData: {
|
||||
count: 1,
|
||||
notes: '通过任务卡片完成'
|
||||
}
|
||||
})).unwrap();
|
||||
|
||||
// 触发任务完成回调
|
||||
onTaskCompleted?.(task);
|
||||
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '完成任务失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipTask = async () => {
|
||||
// 如果任务已经完成或已跳过,不执行任何操作
|
||||
if (task.status === 'completed' || task.status === 'skipped') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示确认弹窗
|
||||
showConfirm(
|
||||
{
|
||||
title: '确认跳过任务',
|
||||
message: `确定要跳过任务"${task.title}"吗?\n\n跳过后的任务将不会显示在任务列表中,且无法恢复。`,
|
||||
confirmText: '跳过',
|
||||
cancelText: '取消',
|
||||
destructive: true,
|
||||
icon: 'warning',
|
||||
iconColor: '#F59E0B',
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
// 调用跳过任务 API
|
||||
await dispatch(skipTask({
|
||||
taskId: task.id,
|
||||
skipData: {
|
||||
reason: '用户主动跳过'
|
||||
}
|
||||
})).unwrap();
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '跳过任务失败,请重试');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleTaskPress = () => {
|
||||
router.push(`/task-detail?taskId=${task.id}`);
|
||||
};
|
||||
|
||||
const renderActionIcons = () => {
|
||||
if (task.status === 'completed' || task.status === 'overdue' || task.status === 'skipped') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.actionIconsContainer}>
|
||||
{/* 完成任务图标 */}
|
||||
<TouchableOpacity
|
||||
style={styles.iconContainer}
|
||||
onPress={handleCompleteTask}
|
||||
>
|
||||
<Image
|
||||
source={require('@/assets/images/task/icon-complete-gradient.png')}
|
||||
style={styles.taskIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 跳过任务图标 - 仅对进行中的任务显示 */}
|
||||
{task.status === 'pending' && (
|
||||
<TouchableOpacity
|
||||
style={styles.skipIconContainer}
|
||||
onPress={handleSkipTask}
|
||||
>
|
||||
<Image
|
||||
source={require('@/assets/images/task/icon-skip.png')}
|
||||
style={styles.taskIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: task.status === 'completed'
|
||||
? 'rgba(248, 250, 252, 0.8)' // 已完成任务使用稍微透明的背景色
|
||||
: colorTokens.background
|
||||
}
|
||||
]}
|
||||
onPress={handleTaskPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.cardContent}>
|
||||
{/* 左侧图标区域 */}
|
||||
<View style={styles.iconSection}>
|
||||
<View style={[
|
||||
styles.iconCircle,
|
||||
{
|
||||
backgroundColor: task.status === 'completed'
|
||||
? '#EDE9FE' // 完成状态使用更深的紫色背景
|
||||
: '#F3E8FF',
|
||||
}
|
||||
]}>
|
||||
<Image
|
||||
source={require('@/assets/images/task/icon-copy.png')}
|
||||
style={styles.taskIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧信息区域 */}
|
||||
<View style={styles.infoSection}>
|
||||
{/* 任务标题 */}
|
||||
<Text style={[
|
||||
styles.title,
|
||||
{
|
||||
color: task.status === 'completed'
|
||||
? '#6B7280'
|
||||
: colorTokens.text,
|
||||
}
|
||||
]} numberOfLines={1}>
|
||||
{task.title}
|
||||
</Text>
|
||||
|
||||
{/* 进度条 */}
|
||||
<View style={styles.progressContainer}>
|
||||
{/* 背景进度条 */}
|
||||
<View style={styles.progressBackground} />
|
||||
|
||||
{/* 实际进度条 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
width: progressAnimation.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: ['6%', '100%'], // 最小显示6%确保可见
|
||||
}),
|
||||
backgroundColor: task.status === 'completed'
|
||||
? '#8B5CF6' // 完成状态也使用紫色
|
||||
: task.progressPercentage > 0
|
||||
? '#8B5CF6'
|
||||
: '#C7D2FE', // 浅紫色,表示待开始
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 进度文字 */}
|
||||
<Text style={[
|
||||
styles.progressText,
|
||||
{
|
||||
color: task.progressPercentage > 20 || task.status === 'completed'
|
||||
? '#FFFFFF'
|
||||
: '#374151', // 进度较少时使用深色文字
|
||||
}
|
||||
]}>
|
||||
{task.currentCount}/{task.targetCount} 次
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{renderActionIcons()}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 14,
|
||||
borderRadius: 30,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
cardContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
iconSection: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#F3E8FF', // 浅紫色背景
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoSection: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 20,
|
||||
color: '#1F2937', // 深蓝紫色文字
|
||||
marginBottom: 2,
|
||||
},
|
||||
progressContainer: {
|
||||
position: 'relative',
|
||||
height: 14,
|
||||
justifyContent: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
progressBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 14,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 10,
|
||||
},
|
||||
progressBar: {
|
||||
height: 14,
|
||||
borderRadius: 10,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
minWidth: '6%', // 确保最小宽度可见
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textAlign: 'center',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
actionIconsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
skipIconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
taskIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
// 保留其他样式以备后用
|
||||
header: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
titleSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
statusTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
statusTagText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
},
|
||||
priorityTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
priorityTagText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
progressBarOld: {
|
||||
height: 6,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 3,
|
||||
marginBottom: 16,
|
||||
overflow: 'visible',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
},
|
||||
progressGlow: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 8,
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: 3,
|
||||
},
|
||||
progressTextContainer: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: -6,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
zIndex: 1,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
teamSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatars: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 24,
|
||||
marginRight: -8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
infoTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
infoTagText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
},
|
||||
});
|
||||
|
||||