feat(challenges): 添加自定义挑战功能和多语言支持
- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等 - 实现挑战邀请码系统,支持通过邀请码加入自定义挑战 - 完善挑战详情页面的多语言翻译支持 - 优化用户认证状态检查逻辑,使用token作为主要判断依据 - 添加阿里字体文件支持,提升UI显示效果 - 改进确认弹窗组件,支持Liquid Glass效果和自定义内容 - 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
# 项目当前状态
|
# 项目当前状态
|
||||||
|
|
||||||
## 应用基本信息
|
## 应用基本信息
|
||||||
|
|
||||||
- **应用名称**: Out Live(超越生命)
|
- **应用名称**: Out Live(超越生命)
|
||||||
- **版本**: 1.0.19
|
- **版本**: 1.0.19
|
||||||
- **Bundle ID**: com.anonymous.digitalpilates
|
- **Bundle ID**: com.anonymous.digitalpilates
|
||||||
@@ -9,8 +10,9 @@
|
|||||||
- **架构**: Expo Prebuild 后的 React Native 应用
|
- **架构**: Expo Prebuild 后的 React Native 应用
|
||||||
|
|
||||||
## 当前开发状态
|
## 当前开发状态
|
||||||
|
|
||||||
- **开发阶段**: 生产就绪版本
|
- **开发阶段**: 生产就绪版本
|
||||||
- **最后更新**: 2025年10月
|
- **最后更新**: 2025 年 11 月
|
||||||
- **主要功能**: 已完成核心健康数据追踪、AI 教练、目标管理、轻断食等功能
|
- **主要功能**: 已完成核心健康数据追踪、AI 教练、目标管理、轻断食等功能
|
||||||
- **状态管理**: 使用 Redux Toolkit 进行状态管理
|
- **状态管理**: 使用 Redux Toolkit 进行状态管理
|
||||||
- **数据存储**: 本地使用 expo-sqlite/kv-store,远程 API 集成
|
- **数据存储**: 本地使用 expo-sqlite/kv-store,远程 API 集成
|
||||||
@@ -18,41 +20,48 @@
|
|||||||
## 核心功能实现状态
|
## 核心功能实现状态
|
||||||
|
|
||||||
### 健康数据追踪 ✅
|
### 健康数据追踪 ✅
|
||||||
|
|
||||||
- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据
|
- HealthKit 集成完成,支持步数、心率、HRV、睡眠等数据
|
||||||
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
- 活动圆环显示(活动卡路里、锻炼分钟、站立小时)
|
||||||
- 实时健康数据监控和历史数据查看
|
- 实时健康数据监控和历史数据查看
|
||||||
- 健康权限管理系统
|
- 健康权限管理系统
|
||||||
|
|
||||||
### 营养管理 ✅
|
### 营养管理 ✅
|
||||||
|
|
||||||
- 饮食记录功能(文字、语音、拍照识别)
|
- 饮食记录功能(文字、语音、拍照识别)
|
||||||
- 营养成分分析和卡路里计算
|
- 营养成分分析和卡路里计算
|
||||||
- 食物库和自定义食物功能
|
- 食物库和自定义食物功能
|
||||||
- 营养标签识别
|
- 营养标签识别
|
||||||
|
|
||||||
### 目标与习惯管理 ✅
|
### 目标与习惯管理 ✅
|
||||||
|
|
||||||
- 目标创建、编辑、删除功能
|
- 目标创建、编辑、删除功能
|
||||||
- 任务分解和进度追踪
|
- 任务分解和进度追踪
|
||||||
- 智能提醒系统
|
- 智能提醒系统
|
||||||
- 目标完成统计和分析
|
- 目标完成统计和分析
|
||||||
|
|
||||||
### 轻断食功能 ✅
|
### 轻断食功能 ✅
|
||||||
- 多种预设断食方案(16:8、18:6等)
|
|
||||||
|
- 多种预设断食方案(16:8、18:6 等)
|
||||||
- 实时断食进度显示
|
- 实时断食进度显示
|
||||||
- 断食提醒和通知
|
- 断食提醒和通知
|
||||||
- 断食历史记录
|
- 断食历史记录
|
||||||
|
|
||||||
### AI 教练系统 ✅
|
### AI 教练系统 ✅
|
||||||
|
|
||||||
- AI 对话功能(流式响应)
|
- AI 对话功能(流式响应)
|
||||||
- 体态评估(照片分析)
|
- 体态评估(照片分析)
|
||||||
- 个性化健康建议
|
- 个性化健康建议
|
||||||
- 情绪分析(基于 HRV)
|
- 情绪分析(基于 HRV)
|
||||||
|
|
||||||
### 社区与挑战 ✅
|
### 社区与挑战 ✅
|
||||||
|
|
||||||
- 挑战赛参与和排行榜
|
- 挑战赛参与和排行榜
|
||||||
- 成就系统
|
- 成就系统
|
||||||
- 社交分享功能
|
- 社交分享功能
|
||||||
|
|
||||||
### 训练计划 ✅
|
### 训练计划 ✅
|
||||||
|
|
||||||
- 个性化训练计划生成
|
- 个性化训练计划生成
|
||||||
- 运动库和动作指导
|
- 运动库和动作指导
|
||||||
- 训练进度记录
|
- 训练进度记录
|
||||||
@@ -60,6 +69,7 @@
|
|||||||
## 技术架构状态
|
## 技术架构状态
|
||||||
|
|
||||||
### 前端架构 ✅
|
### 前端架构 ✅
|
||||||
|
|
||||||
- React Native 0.81.4 + Expo 54
|
- React Native 0.81.4 + Expo 54
|
||||||
- TypeScript 全面覆盖
|
- TypeScript 全面覆盖
|
||||||
- Expo Router 6.0 用于路由管理
|
- Expo Router 6.0 用于路由管理
|
||||||
@@ -67,12 +77,14 @@
|
|||||||
- Liquid Glass 设计风格实现
|
- Liquid Glass 设计风格实现
|
||||||
|
|
||||||
### 后端集成 ✅
|
### 后端集成 ✅
|
||||||
|
|
||||||
- RESTful API 集成(API 基础地址:https://pilate.richarjiang.com)
|
- RESTful API 集成(API 基础地址:https://pilate.richarjiang.com)
|
||||||
- 用户认证和授权
|
- 用户认证和授权
|
||||||
- 数据同步和备份
|
- 数据同步和备份
|
||||||
- 推送通知服务
|
- 推送通知服务
|
||||||
|
|
||||||
### 原生功能 ✅
|
### 原生功能 ✅
|
||||||
|
|
||||||
- HealthKit 深度集成
|
- HealthKit 深度集成
|
||||||
- 推送通知(本地和远程)
|
- 推送通知(本地和远程)
|
||||||
- 快捷动作(Quick Actions)
|
- 快捷动作(Quick Actions)
|
||||||
@@ -82,32 +94,39 @@
|
|||||||
## 当前开发重点
|
## 当前开发重点
|
||||||
|
|
||||||
### 近期更新
|
### 近期更新
|
||||||
1. **性能优化**: 优化健康数据加载和图表渲染性能
|
|
||||||
2. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
1. **多语言支持**: 完善挑战页面的多语言翻译支持,建立翻译最佳实践指南
|
||||||
3. **数据同步**: 增强离线功能和数据同步稳定性
|
2. **性能优化**: 优化健康数据加载和图表渲染性能
|
||||||
4. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
3. **用户体验**: 改进 Liquid Glass 设计效果和交互动画
|
||||||
|
4. **数据同步**: 增强离线功能和数据同步稳定性
|
||||||
|
5. **AI 功能**: 扩展 AI 教练对话能力和分析精度
|
||||||
|
|
||||||
### 待解决问题
|
### 待解决问题
|
||||||
1. **测试覆盖**: 自动化测试覆盖率需要提升
|
|
||||||
2. **错误监控**: 需要集成更完善的错误监控和分析
|
1. **多语言覆盖**: 其他页面的多语言翻译支持需要逐步完善
|
||||||
3. **性能监控**: 应用性能监控和分析工具集成
|
2. **测试覆盖**: 自动化测试覆盖率需要提升
|
||||||
4. **文档完善**: API 文档和组件文档需要进一步完善
|
3. **错误监控**: 需要集成更完善的错误监控和分析
|
||||||
|
4. **性能监控**: 应用性能监控和分析工具集成
|
||||||
|
5. **文档完善**: API 文档和组件文档需要进一步完善
|
||||||
|
|
||||||
## 代码质量状态
|
## 代码质量状态
|
||||||
|
|
||||||
### 代码规范 ✅
|
### 代码规范 ✅
|
||||||
|
|
||||||
- ESLint 配置完善(eslint-config-expo)
|
- ESLint 配置完善(eslint-config-expo)
|
||||||
- Prettier 代码格式化
|
- Prettier 代码格式化
|
||||||
- TypeScript 严格模式
|
- TypeScript 严格模式
|
||||||
- 组件和函数命名规范
|
- 组件和函数命名规范
|
||||||
|
|
||||||
### 项目结构 ✅
|
### 项目结构 ✅
|
||||||
|
|
||||||
- 清晰的目录结构(app/、components/、services/、store/、utils/)
|
- 清晰的目录结构(app/、components/、services/、store/、utils/)
|
||||||
- 功能模块化组织
|
- 功能模块化组织
|
||||||
- 类型定义完整
|
- 类型定义完整
|
||||||
- 常量和配置集中管理
|
- 常量和配置集中管理
|
||||||
|
|
||||||
### 状态管理 ✅
|
### 状态管理 ✅
|
||||||
|
|
||||||
- Redux Toolkit 标准实现
|
- Redux Toolkit 标准实现
|
||||||
- 异步操作处理规范
|
- 异步操作处理规范
|
||||||
- 数据持久化策略
|
- 数据持久化策略
|
||||||
@@ -116,12 +135,14 @@
|
|||||||
## 部署和发布
|
## 部署和发布
|
||||||
|
|
||||||
### 构建配置 ✅
|
### 构建配置 ✅
|
||||||
|
|
||||||
- Expo Prebuild 配置
|
- Expo Prebuild 配置
|
||||||
- iOS 证书和配置文件
|
- iOS 证书和配置文件
|
||||||
- App Store 发布配置
|
- App Store 发布配置
|
||||||
- 自动化构建流程
|
- 自动化构建流程
|
||||||
|
|
||||||
### 发布状态 ✅
|
### 发布状态 ✅
|
||||||
|
|
||||||
- App Store 已发布版本
|
- App Store 已发布版本
|
||||||
- 支持 OTA 更新
|
- 支持 OTA 更新
|
||||||
- 崩溃监控和分析
|
- 崩溃监控和分析
|
||||||
@@ -130,12 +151,14 @@
|
|||||||
## 团队协作
|
## 团队协作
|
||||||
|
|
||||||
### 开发工具 ✅
|
### 开发工具 ✅
|
||||||
|
|
||||||
- Git 版本控制
|
- Git 版本控制
|
||||||
- VS Code 开发环境
|
- VS Code 开发环境
|
||||||
- Expo 开发者工具
|
- Expo 开发者工具
|
||||||
- iOS 模拟器和真机调试
|
- iOS 模拟器和真机调试
|
||||||
|
|
||||||
### 文档状态 🔄
|
### 文档状态 🔄
|
||||||
|
|
||||||
- API 文档部分完成
|
- API 文档部分完成
|
||||||
- 组件文档需要补充
|
- 组件文档需要补充
|
||||||
- 部署文档完善
|
- 部署文档完善
|
||||||
@@ -143,20 +166,24 @@
|
|||||||
|
|
||||||
## 下一步计划
|
## 下一步计划
|
||||||
|
|
||||||
### 短期目标(1-2个月)
|
### 短期目标(1-2 个月)
|
||||||
1. 完善自动化测试覆盖
|
|
||||||
2. 优化应用启动性能
|
1. 完善所有核心页面的多语言翻译支持
|
||||||
3. 增强错误监控和分析
|
2. 完善自动化测试覆盖
|
||||||
4. 改进用户引导流程
|
3. 优化应用启动性能
|
||||||
|
4. 增强错误监控和分析
|
||||||
|
5. 改进用户引导流程
|
||||||
|
|
||||||
|
### 中期目标(3-6 个月)
|
||||||
|
|
||||||
### 中期目标(3-6个月)
|
|
||||||
1. 扩展 AI 教练功能
|
1. 扩展 AI 教练功能
|
||||||
2. 增加更多健康指标追踪
|
2. 增加更多健康指标追踪
|
||||||
3. 优化数据同步策略
|
3. 优化数据同步策略
|
||||||
4. 增强社交功能
|
4. 增强社交功能
|
||||||
|
|
||||||
### 长期目标(6个月以上)
|
### 长期目标(6 个月以上)
|
||||||
|
|
||||||
1. 支持 Apple Watch 应用
|
1. 支持 Apple Watch 应用
|
||||||
2. 集成更多第三方健康设备
|
2. 集成更多第三方健康设备
|
||||||
3. 开发 Web 端管理界面
|
3. 开发 Web 端管理界面
|
||||||
4. 扩展企业健康解决方案
|
4. 扩展企业健康解决方案
|
||||||
|
|||||||
@@ -5,26 +5,31 @@
|
|||||||
**最后更新**: 2025-10-24
|
**最后更新**: 2025-10-24
|
||||||
|
|
||||||
### 重要规则
|
### 重要规则
|
||||||
|
|
||||||
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
|
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
|
||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
|
|
||||||
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons,以保持图标库的一致性。
|
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons,以保持图标库的一致性。
|
||||||
|
|
||||||
### 解决方案
|
### 解决方案
|
||||||
|
|
||||||
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
|
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
|
||||||
|
|
||||||
### 实现模式
|
### 实现模式
|
||||||
|
|
||||||
#### 1. 替换导入语句
|
#### 1. 替换导入语句
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ 禁止使用
|
// ❌ 禁止使用
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
// ✅ 正确写法
|
// ✅ 正确写法
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 替换图标名称和属性
|
#### 2. 替换图标名称和属性
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ 禁止使用
|
// ❌ 禁止使用
|
||||||
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
|
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
|
||||||
@@ -34,6 +39,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 常见图标映射
|
#### 3. 常见图标映射
|
||||||
|
|
||||||
- `arrow-back-ios` → `chevron-back` (返回按钮)
|
- `arrow-back-ios` → `chevron-back` (返回按钮)
|
||||||
- `auto-awesome` → `star` (星星/自动推荐)
|
- `auto-awesome` → `star` (星星/自动推荐)
|
||||||
- `tips-and-updates` → `bulb` (提示/建议)
|
- `tips-and-updates` → `bulb` (提示/建议)
|
||||||
@@ -42,6 +48,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
- `remove` → `remove` (移除/删除,名称相同)
|
- `remove` → `remove` (移除/删除,名称相同)
|
||||||
|
|
||||||
### 重要注意事项
|
### 重要注意事项
|
||||||
|
|
||||||
1. **图标大小调整**:Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
|
1. **图标大小调整**:Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
|
||||||
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
|
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
|
||||||
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
|
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
|
||||||
@@ -49,6 +56,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
|
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
|
||||||
|
|
||||||
### 参考实现
|
### 参考实现
|
||||||
|
|
||||||
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
|
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
|
||||||
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
|
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
|
||||||
|
|
||||||
@@ -57,21 +65,25 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
**最后更新**: 2025-10-24
|
**最后更新**: 2025-10-24
|
||||||
|
|
||||||
### 重要原则
|
### 重要原则
|
||||||
|
|
||||||
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
|
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
|
||||||
|
|
||||||
### 实现模式
|
### 实现模式
|
||||||
|
|
||||||
#### 1. 导入必要的组件
|
#### 1. 导入必要的组件
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 检查设备支持情况
|
#### 2. 检查设备支持情况
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const isGlassAvailable = isLiquidGlassAvailable();
|
const isGlassAvailable = isLiquidGlassAvailable();
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 实现条件渲染的按钮
|
#### 3. 实现条件渲染的按钮
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
@@ -81,9 +93,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
|||||||
{isLiquidGlassAvailable() ? (
|
{isLiquidGlassAvailable() ? (
|
||||||
<GlassView
|
<GlassView
|
||||||
style={styles.button}
|
style={styles.button}
|
||||||
glassEffectStyle="clear" // 或 "regular"
|
glassEffectStyle="clear" // 或 "regular"
|
||||||
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
||||||
isInteractive={true} // 启用交互反馈
|
isInteractive={true} // 启用交互反馈
|
||||||
>
|
>
|
||||||
<Ionicons name="icon-name" size={20} color="#333" />
|
<Ionicons name="icon-name" size={20} color="#333" />
|
||||||
</GlassView>
|
</GlassView>
|
||||||
@@ -96,26 +108,28 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 4. 定义样式
|
#### 4. 定义样式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
button: {
|
button: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 20, // 圆形按钮
|
borderRadius: 20, // 圆形按钮
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||||
// 其他通用样式...
|
// 其他通用样式...
|
||||||
},
|
},
|
||||||
fallbackButton: {
|
fallbackButton: {
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重要注意事项
|
### 重要注意事项
|
||||||
|
|
||||||
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||||
@@ -124,6 +138,7 @@ const styles = StyleSheet.create({
|
|||||||
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||||
|
|
||||||
### 常用配置
|
### 常用配置
|
||||||
|
|
||||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||||
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
|
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
|
||||||
@@ -132,6 +147,7 @@ const styles = StyleSheet.create({
|
|||||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||||
|
|
||||||
### 参考实现
|
### 参考实现
|
||||||
|
|
||||||
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
|
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
|
||||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||||
@@ -141,24 +157,29 @@ const styles = StyleSheet.create({
|
|||||||
**最后更新**: 2025-10-16
|
**最后更新**: 2025-10-16
|
||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
|
|
||||||
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
|
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
|
||||||
|
|
||||||
### 解决方案
|
### 解决方案
|
||||||
|
|
||||||
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
|
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
|
||||||
|
|
||||||
### 实现模式
|
### 实现模式
|
||||||
|
|
||||||
#### 1. 导入必要的 hook
|
#### 1. 导入必要的 hook
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
import { useSafeAreaTop } from "@/hooks/useSafeAreaWithPadding";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 在组件中获取 safeAreaTop
|
#### 2. 在组件中获取 safeAreaTop
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const safeAreaTop = useSafeAreaTop()
|
const safeAreaTop = useSafeAreaTop();
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 应用到内容容器
|
#### 3. 应用到内容容器
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 方式1: 直接应用到 View 组件
|
// 方式1: 直接应用到 View 组件
|
||||||
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
||||||
@@ -175,11 +196,13 @@ const safeAreaTop = useSafeAreaTop()
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 重要注意事项
|
### 重要注意事项
|
||||||
|
|
||||||
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
|
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
|
||||||
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
|
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
|
||||||
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
|
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
|
||||||
|
|
||||||
### 示例代码
|
### 示例代码
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ 错误写法 - 在 StyleSheet 中使用变量
|
// ❌ 错误写法 - 在 StyleSheet 中使用变量
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
@@ -193,6 +216,7 @@ const styles = StyleSheet.create({
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 参考页面
|
### 参考页面
|
||||||
|
|
||||||
- `app/steps/detail.tsx`
|
- `app/steps/detail.tsx`
|
||||||
- `app/water/detail.tsx`
|
- `app/water/detail.tsx`
|
||||||
- `app/profile/goals.tsx`
|
- `app/profile/goals.tsx`
|
||||||
@@ -204,24 +228,29 @@ const styles = StyleSheet.create({
|
|||||||
**最后更新**: 2025-10-16
|
**最后更新**: 2025-10-16
|
||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
|
|
||||||
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
|
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
|
||||||
|
|
||||||
### 解决方案
|
### 解决方案
|
||||||
|
|
||||||
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
|
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
|
||||||
|
|
||||||
### 实现模式
|
### 实现模式
|
||||||
|
|
||||||
#### 1. 导入必要的组件和函数
|
#### 1. 导入必要的组件和函数
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 检查设备支持情况
|
#### 2. 检查设备支持情况
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const isGlassAvailable = isLiquidGlassAvailable();
|
const isGlassAvailable = isLiquidGlassAvailable();
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 实现条件渲染的按钮
|
#### 3. 实现条件渲染的按钮
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
@@ -231,9 +260,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
|||||||
{isGlassAvailable ? (
|
{isGlassAvailable ? (
|
||||||
<GlassView
|
<GlassView
|
||||||
style={styles.glassButton}
|
style={styles.glassButton}
|
||||||
glassEffectStyle="clear" // 或 "regular"
|
glassEffectStyle="clear" // 或 "regular"
|
||||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||||
isInteractive={true} // 启用交互反馈
|
isInteractive={true} // 启用交互反馈
|
||||||
>
|
>
|
||||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||||
</GlassView>
|
</GlassView>
|
||||||
@@ -246,25 +275,27 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 4. 定义样式
|
#### 4. 定义样式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
glassButton: {
|
glassButton: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 18, // 圆形按钮
|
borderRadius: 18, // 圆形按钮
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||||
},
|
},
|
||||||
fallbackButton: {
|
fallbackButton: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(244, 67, 54, 0.3)',
|
borderColor: "rgba(244, 67, 54, 0.3)",
|
||||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
backgroundColor: "rgba(244, 67, 54, 0.1)",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重要注意事项
|
### 重要注意事项
|
||||||
|
|
||||||
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||||
@@ -272,6 +303,7 @@ const styles = StyleSheet.create({
|
|||||||
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||||
|
|
||||||
### 常用配置
|
### 常用配置
|
||||||
|
|
||||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||||
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
||||||
@@ -279,6 +311,7 @@ const styles = StyleSheet.create({
|
|||||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||||
|
|
||||||
### 参考实现
|
### 参考实现
|
||||||
|
|
||||||
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
|
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
|
||||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||||
@@ -288,27 +321,33 @@ const styles = StyleSheet.create({
|
|||||||
**最后更新**: 2025-10-16
|
**最后更新**: 2025-10-16
|
||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
|
|
||||||
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
|
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
|
||||||
|
|
||||||
### 解决方案
|
### 解决方案
|
||||||
|
|
||||||
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
|
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
|
||||||
|
|
||||||
### 权限校验原则
|
### 权限校验原则
|
||||||
|
|
||||||
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
|
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
|
||||||
|
|
||||||
### 实现模式
|
### 实现模式
|
||||||
|
|
||||||
#### 1. 导入必要的 hook
|
#### 1. 导入必要的 hook
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from "@/hooks/useAuthGuard";
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 在组件中获取方法
|
#### 2. 在组件中获取方法
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 替换导航操作
|
#### 3. 替换导航操作
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ 原来的写法 - 没有登录验证
|
// ❌ 原来的写法 - 没有登录验证
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -324,6 +363,7 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 4. 服务端接口调用的登录验证
|
#### 4. 服务端接口调用的登录验证
|
||||||
|
|
||||||
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
|
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -347,33 +387,37 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
|
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{isLiquidGlassAvailable() ? (
|
{
|
||||||
<TouchableOpacity
|
isLiquidGlassAvailable() ? (
|
||||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
<TouchableOpacity
|
||||||
activeOpacity={0.7}
|
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||||
>
|
activeOpacity={0.7}
|
||||||
<GlassView
|
>
|
||||||
style={styles.historyButton}
|
<GlassView
|
||||||
glassEffectStyle="clear"
|
style={styles.historyButton}
|
||||||
tintColor="rgba(255, 255, 255, 0.2)"
|
glassEffectStyle="clear"
|
||||||
isInteractive={true}
|
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" />
|
<Ionicons name="time-outline" size={24} color="#333" />
|
||||||
</GlassView>
|
</TouchableOpacity>
|
||||||
</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` 可以确保登录后自动跳转到目标页面
|
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
|
||||||
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
|
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
|
||||||
3. **登录重定向**:登录页面会接收 `redirectTo` 和 `redirectParams` 参数用于登录后跳转
|
3. **登录重定向**:登录页面会接收 `redirectTo` 和 `redirectParams` 参数用于登录后跳转
|
||||||
@@ -382,16 +426,19 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
|||||||
6. **异步处理**:`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
|
6. **异步处理**:`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
|
||||||
|
|
||||||
### 其他可用方法
|
### 其他可用方法
|
||||||
|
|
||||||
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
|
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
|
||||||
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
|
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
|
||||||
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
|
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
|
||||||
|
|
||||||
### 使用场景选择
|
### 使用场景选择
|
||||||
|
|
||||||
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
|
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
|
||||||
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
|
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
|
||||||
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
|
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
|
||||||
|
|
||||||
### 参考实现
|
### 参考实现
|
||||||
|
|
||||||
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
|
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
|
||||||
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
|
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
|
||||||
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
|
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
|
||||||
@@ -401,39 +448,44 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
|||||||
**最后更新**: 2025-10-16
|
**最后更新**: 2025-10-16
|
||||||
|
|
||||||
### 问题描述
|
### 问题描述
|
||||||
|
|
||||||
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
|
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
|
||||||
|
|
||||||
### 解决方案
|
### 解决方案
|
||||||
|
|
||||||
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
|
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
|
||||||
|
|
||||||
### 实现模式
|
### 实现模式
|
||||||
|
|
||||||
#### 1. 添加新路由常量
|
#### 1. 添加新路由常量
|
||||||
|
|
||||||
在 `constants/Routes.ts` 文件中添加新的路由常量:
|
在 `constants/Routes.ts` 文件中添加新的路由常量:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
// 现有路由...
|
// 现有路由...
|
||||||
|
|
||||||
// 新增路由
|
// 新增路由
|
||||||
FOOD_CAMERA: '/food/camera',
|
FOOD_CAMERA: "/food/camera",
|
||||||
} as const;
|
} as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. 在组件中使用路由常量
|
#### 2. 在组件中使用路由常量
|
||||||
|
|
||||||
导入并使用路由常量,而不是硬编码路径:
|
导入并使用路由常量,而不是硬编码路径:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from "@/constants/Routes";
|
||||||
|
|
||||||
// ❌ 错误写法 - 硬编码路径
|
// ❌ 错误写法 - 硬编码路径
|
||||||
router.push('/food/camera?mealType=dinner');
|
router.push("/food/camera?mealType=dinner");
|
||||||
|
|
||||||
// ✅ 正确写法 - 使用路由常量
|
// ✅ 正确写法 - 使用路由常量
|
||||||
router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`);
|
router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. 结合登录验证使用
|
#### 3. 结合登录验证使用
|
||||||
|
|
||||||
对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用:
|
对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -450,6 +502,7 @@ const { pushIfAuthedElseLogin } = useAuthGuard();
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 重要注意事项
|
### 重要注意事项
|
||||||
|
|
||||||
1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义
|
1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义
|
||||||
2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA`
|
2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA`
|
||||||
3. **路径一致性**:常量名应该清晰表达路由的用途
|
3. **路径一致性**:常量名应该清晰表达路由的用途
|
||||||
@@ -457,26 +510,244 @@ const { pushIfAuthedElseLogin } = useAuthGuard();
|
|||||||
5. **类型安全**:使用 `as const` 确保类型推导
|
5. **类型安全**:使用 `as const` 确保类型推导
|
||||||
|
|
||||||
### 路由分类
|
### 路由分类
|
||||||
|
|
||||||
按照功能模块对路由进行分组:
|
按照功能模块对路由进行分组:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
// Tab路由
|
// Tab路由
|
||||||
TAB_EXPLORE: '/explore',
|
TAB_EXPLORE: "/explore",
|
||||||
TAB_COACH: '/coach',
|
TAB_COACH: "/coach",
|
||||||
|
|
||||||
// 营养相关路由
|
// 营养相关路由
|
||||||
NUTRITION_RECORDS: '/nutrition/records',
|
NUTRITION_RECORDS: "/nutrition/records",
|
||||||
FOOD_LIBRARY: '/food-library',
|
FOOD_LIBRARY: "/food-library",
|
||||||
FOOD_CAMERA: '/food/camera',
|
FOOD_CAMERA: "/food/camera",
|
||||||
|
|
||||||
// 用户相关路由
|
// 用户相关路由
|
||||||
AUTH_LOGIN: '/auth/login',
|
AUTH_LOGIN: "/auth/login",
|
||||||
PROFILE_EDIT: '/profile/edit',
|
PROFILE_EDIT: "/profile/edit",
|
||||||
} as const;
|
} as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 参考实现
|
### 参考实现
|
||||||
|
|
||||||
- `constants/Routes.ts` - 路由常量定义
|
- `constants/Routes.ts` - 路由常量定义
|
||||||
- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证
|
- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证
|
||||||
- `app/food/camera.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. **测试验证**:在开发完成后测试语言切换功能是否正常
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function TabLayout() {
|
|||||||
color: colorTokens.tabIconSelected,
|
color: colorTokens.tabIconSelected,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
marginLeft: 6,
|
marginLeft: 6
|
||||||
}}
|
}}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||||
|
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { useI18n } from '@/hooks/useI18n';
|
||||||
import {
|
import {
|
||||||
fetchChallenges,
|
fetchChallenges,
|
||||||
|
joinChallengeByCode,
|
||||||
|
resetJoinByCodeState,
|
||||||
selectChallengeCards,
|
selectChallengeCards,
|
||||||
selectChallengesListError,
|
selectChallengesListError,
|
||||||
selectChallengesListStatus,
|
selectChallengesListStatus,
|
||||||
|
selectCustomChallengeCards,
|
||||||
|
selectJoinByCodeError,
|
||||||
|
selectJoinByCodeStatus,
|
||||||
|
selectOfficialChallengeCards,
|
||||||
type ChallengeCardViewModel,
|
type ChallengeCardViewModel,
|
||||||
} from '@/store/challengesSlice';
|
} 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 { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
@@ -23,6 +34,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
useWindowDimensions
|
useWindowDimensions
|
||||||
@@ -32,11 +44,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
const AVATAR_SIZE = 36;
|
const AVATAR_SIZE = 36;
|
||||||
const CARD_IMAGE_WIDTH = 132;
|
const CARD_IMAGE_WIDTH = 132;
|
||||||
const CARD_IMAGE_HEIGHT = 96;
|
const CARD_IMAGE_HEIGHT = 96;
|
||||||
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
|
||||||
upcoming: '即将开始',
|
|
||||||
ongoing: '进行中',
|
|
||||||
expired: '已结束',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CAROUSEL_ITEM_SPACING = 16;
|
const CAROUSEL_ITEM_SPACING = 16;
|
||||||
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
||||||
@@ -45,18 +52,32 @@ const DOT_BASE_SIZE = 6;
|
|||||||
export default function ChallengesScreen() {
|
export default function ChallengesScreen() {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { isLoggedIn } = useAuthGuard()
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
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 listStatus = useAppSelector(selectChallengesListStatus);
|
||||||
const listError = useAppSelector(selectChallengesListError);
|
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 ongoingChallenges = useMemo(() => {
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
return challenges.filter((challenge) => {
|
return allChallenges.filter((challenge) => {
|
||||||
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -70,7 +91,7 @@ export default function ChallengesScreen() {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [challenges]);
|
}, [allChallenges]);
|
||||||
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
||||||
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
||||||
|
|
||||||
@@ -85,53 +106,132 @@ export default function ChallengesScreen() {
|
|||||||
? ['#1f2230', '#10131e']
|
? ['#1f2230', '#10131e']
|
||||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
: [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 = () => {
|
const renderChallenges = () => {
|
||||||
if (listStatus === 'loading' && challenges.length === 0) {
|
if (listStatus === 'loading' && allChallenges.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.stateContainer}>
|
<View style={styles.stateContainer}>
|
||||||
<ActivityIndicator color={colorTokens.primary} />
|
<ActivityIndicator color={colorTokens.primary} />
|
||||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listStatus === 'failed' && challenges.length === 0) {
|
if (listStatus === 'failed' && allChallenges.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.stateContainer}>
|
<View style={styles.stateContainer}>
|
||||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||||
{listError ?? '加载挑战失败,请稍后重试'}
|
{listError ?? t('challenges.loadFailed')}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
onPress={() => dispatch(fetchChallenges())}
|
onPress={() => dispatch(fetchChallenges())}
|
||||||
>
|
>
|
||||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (challenges.length === 0) {
|
if (customChallenges.length === 0 && officialChallenges.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.stateContainer}>
|
<View style={styles.stateContainer}>
|
||||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return challenges.map((challenge) => (
|
return (
|
||||||
<ChallengeCard
|
<View style={styles.cardGroups}>
|
||||||
key={challenge.id}
|
{joinedCustomChallenges.length ? (
|
||||||
challenge={challenge}
|
<>
|
||||||
surfaceColor={colorTokens.surface}
|
<View style={styles.sectionHeaderRow}>
|
||||||
textColor={colorTokens.text}
|
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
|
||||||
mutedColor={colorTokens.textSecondary}
|
</View>
|
||||||
onPress={() =>
|
<View style={styles.cardsContainer}>
|
||||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
{joinedCustomChallenges.map((challenge) => (
|
||||||
}
|
<ChallengeCard
|
||||||
/>
|
key={challenge.id}
|
||||||
));
|
challenge={challenge}
|
||||||
|
surfaceColor={colorTokens.surface}
|
||||||
|
textColor={colorTokens.text}
|
||||||
|
mutedColor={colorTokens.textSecondary}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
|
||||||
|
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
|
||||||
|
</View>
|
||||||
|
{officialChallenges.length ? (
|
||||||
|
<View style={styles.cardsContainer}>
|
||||||
|
{officialChallenges.map((challenge) => (
|
||||||
|
<ChallengeCard
|
||||||
|
key={challenge.id}
|
||||||
|
challenge={challenge}
|
||||||
|
surfaceColor={colorTokens.surface}
|
||||||
|
textColor={colorTokens.text}
|
||||||
|
mutedColor={colorTokens.textSecondary}
|
||||||
|
onPress={() =>
|
||||||
|
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.stateContainer, styles.customEmpty]}>
|
||||||
|
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,19 +246,42 @@ export default function ChallengesScreen() {
|
|||||||
>
|
>
|
||||||
<View style={styles.headerRow}>
|
<View style={styles.headerRow}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
<Text style={[styles.title, { color: colorTokens.text }]}>{t('challenges.title')}</Text>
|
||||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerActions}>
|
||||||
|
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
|
||||||
|
{glassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.joinButtonGlass}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(255,255,255,0.18)"
|
||||||
|
isInteractive
|
||||||
|
>
|
||||||
|
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||||
|
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
|
||||||
|
{glassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.createButton}
|
||||||
|
tintColor="rgba(255,255,255,0.22)"
|
||||||
|
isInteractive
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color="#0f1528" />
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.createButton, styles.createButtonFallback]}>
|
||||||
|
<Ionicons name="add" size={18} color={colorTokens.text} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={styles.giftButton}
|
|
||||||
>
|
|
||||||
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity> */}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{ongoingChallenges.length ? (
|
{ongoingChallenges.length ? (
|
||||||
@@ -175,6 +298,34 @@ export default function ChallengesScreen() {
|
|||||||
|
|
||||||
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||||||
</ScrollView>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +339,8 @@ type ChallengeCardProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: 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 (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -238,7 +390,7 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
|||||||
style={[styles.cardParticipants, { color: mutedColor }]}
|
style={[styles.cardParticipants, { color: mutedColor }]}
|
||||||
>
|
>
|
||||||
{challenge.participantsLabel}
|
{challenge.participantsLabel}
|
||||||
{challenge.isJoined ? ' · 已加入' : ''}
|
{challenge.isJoined ? ` · ${t('challenges.joined')}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
{challenge.avatars.length ? (
|
{challenge.avatars.length ? (
|
||||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||||
@@ -328,7 +480,7 @@ function OngoingChallengesCarousel({
|
|||||||
>
|
>
|
||||||
<ChallengeProgressCard
|
<ChallengeProgressCard
|
||||||
title={item.title}
|
title={item.title}
|
||||||
endAt={item.endAt}
|
endAt={item.endAt as string}
|
||||||
progress={item.progress}
|
progress={item.progress}
|
||||||
style={styles.carouselProgressCard}
|
style={styles.carouselProgressCard}
|
||||||
backgroundColors={[colorTokens.card, colorTokens.card]}
|
backgroundColors={[colorTokens.card, colorTokens.card]}
|
||||||
@@ -453,31 +605,79 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
|
fontFamily: 'AliBold'
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
},
|
},
|
||||||
giftShadow: {
|
headerActions: {
|
||||||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
flexDirection: 'row',
|
||||||
shadowOffset: { width: 0, height: 8 },
|
alignItems: 'center',
|
||||||
shadowOpacity: 0.35,
|
|
||||||
shadowRadius: 12,
|
|
||||||
elevation: 8,
|
|
||||||
borderRadius: 26,
|
|
||||||
},
|
},
|
||||||
giftButton: {
|
joinButtonGlass: {
|
||||||
width: 32,
|
paddingHorizontal: 16,
|
||||||
height: 32,
|
paddingVertical: 10,
|
||||||
borderRadius: 26,
|
borderRadius: 16,
|
||||||
|
minWidth: 70,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: '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: {
|
cardsContainer: {
|
||||||
gap: 18,
|
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: {
|
carouselContainer: {
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
@@ -558,16 +758,19 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliBold',
|
||||||
},
|
},
|
||||||
|
|
||||||
cardDate: {
|
cardDate: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
cardParticipants: {
|
cardParticipants: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
},
|
},
|
||||||
cardExpired: {
|
cardExpired: {
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
@@ -597,6 +800,7 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#f7f9ff',
|
color: '#f7f9ff',
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
|
fontFamily: 'AliRegular',
|
||||||
},
|
},
|
||||||
cardProgress: {
|
cardProgress: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -617,4 +821,25 @@ const styles = StyleSheet.create({
|
|||||||
avatarOffset: {
|
avatarOffset: {
|
||||||
marginLeft: -12,
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -598,6 +598,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#192126',
|
color: '#192126',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
},
|
},
|
||||||
debugButtonsContainer: {
|
debugButtonsContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -510,6 +510,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||||
|
AliRegular: require('../assets/fonts/ali-regular.ttf'),
|
||||||
|
AliBold: require('../assets/fonts/ali-bold.ttf'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
|
|||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import { ChallengeSource } from '@/services/challengesApi';
|
||||||
import {
|
import {
|
||||||
fetchChallengeDetail,
|
fetchChallengeDetail,
|
||||||
fetchChallengeRankings,
|
fetchChallengeRankings,
|
||||||
@@ -23,13 +24,17 @@ import {
|
|||||||
} from '@/store/challengesSlice';
|
} from '@/store/challengesSlice';
|
||||||
import { Toast } from '@/utils/toast.utils';
|
import { Toast } from '@/utils/toast.utils';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import LottieView from 'lottie-react-native';
|
import LottieView from 'lottie-react-native';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -87,6 +92,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { ensureLoggedIn } = useAuthGuard();
|
const { ensureLoggedIn } = useAuthGuard();
|
||||||
|
|
||||||
@@ -155,6 +161,24 @@ export default function ChallengeDetailScreen() {
|
|||||||
}, [showCelebration]);
|
}, [showCelebration]);
|
||||||
|
|
||||||
const progress = challenge?.progress;
|
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 rankingData = useMemo(() => {
|
||||||
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
||||||
@@ -165,6 +189,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||||
[rankingData],
|
[rankingData],
|
||||||
);
|
);
|
||||||
|
const showShareCode = isJoined && Boolean(challenge?.shareCode);
|
||||||
|
|
||||||
const handleViewAllRanking = () => {
|
const handleViewAllRanking = () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -192,7 +217,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
try {
|
try {
|
||||||
Toast.show({
|
Toast.show({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
text1: '正在生成分享卡片...',
|
text1: t('challengeDetail.share.generating'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 捕获分享卡片视图
|
// 捕获分享卡片视图
|
||||||
@@ -203,8 +228,8 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
// 分享图片
|
// 分享图片
|
||||||
const shareMessage = isJoined && progress
|
const shareMessage = isJoined && progress
|
||||||
? `我正在参与「${challenge.title}」挑战,已完成 ${progress.completed}/${progress.target} 天!一起加入吧!`
|
? t('challengeDetail.share.messageJoined', { title: challenge.title, completed: progress.completed, target: progress.target })
|
||||||
: `发现一个很棒的挑战「${challenge.title}」,一起来参与吧!`;
|
: t('challengeDetail.share.messageNotJoined', { title: challenge.title });
|
||||||
|
|
||||||
await Share.share({
|
await Share.share({
|
||||||
title: challenge.title,
|
title: challenge.title,
|
||||||
@@ -213,7 +238,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('分享失败', error);
|
console.warn('分享失败', error);
|
||||||
Toast.error('分享失败,请稍后重试');
|
Toast.error(t('challengeDetail.share.failed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,7 +259,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
await dispatch(fetchChallengeRankings({ id }));
|
await dispatch(fetchChallengeRankings({ id }));
|
||||||
setShowCelebration(true)
|
setShowCelebration(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error('加入挑战失败')
|
Toast.error(t('challengeDetail.alert.joinFailed'))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,7 +271,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
await dispatch(leaveChallenge(id)).unwrap();
|
await dispatch(leaveChallenge(id)).unwrap();
|
||||||
await dispatch(fetchChallengeDetail(id)).unwrap();
|
await dispatch(fetchChallengeDetail(id)).unwrap();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error('退出挑战失败');
|
Toast.error(t('challengeDetail.alert.leaveFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,34 +279,76 @@ export default function ChallengeDetailScreen() {
|
|||||||
if (!id || leaveStatus === 'loading') {
|
if (!id || leaveStatus === 'loading') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
|
Alert.alert(
|
||||||
{ text: '取消', style: 'cancel' },
|
t('challengeDetail.alert.leaveConfirm.title'),
|
||||||
{
|
t('challengeDetail.alert.leaveConfirm.message'),
|
||||||
text: '退出挑战',
|
[
|
||||||
style: 'destructive',
|
{ text: t('challengeDetail.alert.leaveConfirm.cancel'), style: 'cancel' },
|
||||||
onPress: () => {
|
{
|
||||||
void handleLeave();
|
text: t('challengeDetail.alert.leaveConfirm.confirm'),
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
void handleLeave();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProgressReport = () => {
|
const handleProgressReport = async () => {
|
||||||
if (!id || progressStatus === 'loading') {
|
if (!id || progressStatus === 'loading') {
|
||||||
return;
|
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;
|
const isLoadingInitial = detailStatus === 'loading' && !challenge;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
<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}>
|
<View style={styles.missingContainer}>
|
||||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战,稍后再试试吧。</Text>
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.notFound')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -290,10 +357,10 @@ export default function ChallengeDetailScreen() {
|
|||||||
if (isLoadingInitial) {
|
if (isLoadingInitial) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
<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}>
|
<View style={styles.missingContainer}>
|
||||||
<ActivityIndicator color={colorTokens.primary} />
|
<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>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
@@ -302,43 +369,43 @@ export default function ChallengeDetailScreen() {
|
|||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
<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}>
|
<View style={styles.missingContainer}>
|
||||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||||
{detailError ?? '未找到该挑战,稍后再试试吧。'}
|
{detailError ?? t('challengeDetail.notFound')}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
onPress={() => dispatch(fetchChallengeDetail(id))}
|
onPress={() => dispatch(fetchChallengeDetail(id))}
|
||||||
>
|
>
|
||||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challengeDetail.retry')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
const highlightTitle = challenge.highlightTitle ?? t('challengeDetail.highlight.join.title');
|
||||||
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
const highlightSubtitle = challenge.highlightSubtitle ?? t('challengeDetail.highlight.join.subtitle');
|
||||||
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
|
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
|
||||||
const isUpcoming = challenge.status === 'upcoming';
|
const isUpcoming = challenge.status === 'upcoming';
|
||||||
const isExpired = challenge.status === 'expired';
|
const isExpired = challenge.status === 'expired';
|
||||||
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
||||||
const upcomingHighlightTitle = '挑战即将开始';
|
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
|
||||||
const upcomingHighlightSubtitle = upcomingStartLabel
|
const upcomingHighlightSubtitle = upcomingStartLabel
|
||||||
? `${upcomingStartLabel} 开始,敬请期待`
|
? t('challengeDetail.highlight.upcoming.subtitle', { date: upcomingStartLabel })
|
||||||
: '挑战即将开启,敬请期待';
|
: t('challengeDetail.highlight.upcoming.subtitleFallback');
|
||||||
const upcomingCtaLabel = '挑战即将开始';
|
const upcomingCtaLabel = t('challengeDetail.cta.upcoming');
|
||||||
const expiredEndLabel = formatMonthDay(challenge.endAt);
|
const expiredEndLabel = formatMonthDay(challenge.endAt);
|
||||||
const expiredHighlightTitle = '挑战已结束';
|
const expiredHighlightTitle = t('challengeDetail.highlight.expired.title');
|
||||||
const expiredHighlightSubtitle = expiredEndLabel
|
const expiredHighlightSubtitle = expiredEndLabel
|
||||||
? `${expiredEndLabel} 已截止,期待下一次挑战`
|
? t('challengeDetail.highlight.expired.subtitle', { date: expiredEndLabel })
|
||||||
: '本轮挑战已结束,期待下一次挑战';
|
: t('challengeDetail.highlight.expired.subtitleFallback');
|
||||||
const expiredCtaLabel = '挑战已结束';
|
const expiredCtaLabel = t('challengeDetail.cta.expired');
|
||||||
const leaveHighlightTitle = '先别急着离开';
|
const leaveHighlightTitle = t('challengeDetail.highlight.leave.title');
|
||||||
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
|
const leaveHighlightSubtitle = t('challengeDetail.highlight.leave.subtitle');
|
||||||
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
|
const leaveCtaLabel = leaveStatus === 'loading' ? t('challengeDetail.cta.leaving') : t('challengeDetail.cta.leave');
|
||||||
|
|
||||||
let floatingHighlightTitle = highlightTitle;
|
let floatingHighlightTitle = highlightTitle;
|
||||||
let floatingHighlightSubtitle = highlightSubtitle;
|
let floatingHighlightSubtitle = highlightSubtitle;
|
||||||
@@ -349,8 +416,10 @@ export default function ChallengeDetailScreen() {
|
|||||||
let isDisabledButtonState = false;
|
let isDisabledButtonState = false;
|
||||||
|
|
||||||
if (isJoined) {
|
if (isJoined) {
|
||||||
floatingHighlightTitle = leaveHighlightTitle;
|
floatingHighlightTitle = showShareCode
|
||||||
floatingHighlightSubtitle = leaveHighlightSubtitle;
|
? `分享码 ${challenge?.shareCode ?? ''}`
|
||||||
|
: leaveHighlightTitle;
|
||||||
|
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
|
||||||
floatingCtaLabel = leaveCtaLabel;
|
floatingCtaLabel = leaveCtaLabel;
|
||||||
floatingOnPress = handleLeaveConfirm;
|
floatingOnPress = handleLeaveConfirm;
|
||||||
floatingDisabled = leaveStatus === 'loading';
|
floatingDisabled = leaveStatus === 'loading';
|
||||||
@@ -380,6 +449,23 @@ export default function ChallengeDetailScreen() {
|
|||||||
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||||||
|
|
||||||
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
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 (
|
return (
|
||||||
<View style={styles.safeArea}>
|
<View style={styles.safeArea}>
|
||||||
@@ -411,9 +497,9 @@ export default function ChallengeDetailScreen() {
|
|||||||
// 已加入:显示个人进度
|
// 已加入:显示个人进度
|
||||||
<View style={styles.shareProgressContainer}>
|
<View style={styles.shareProgressContainer}>
|
||||||
<View style={styles.shareProgressHeader}>
|
<View style={styles.shareProgressHeader}>
|
||||||
<Text style={styles.shareProgressLabel}>我的坚持进度</Text>
|
<Text style={styles.shareProgressLabel}>{t('challengeDetail.shareCard.progress.label')}</Text>
|
||||||
<Text style={styles.shareProgressValue}>
|
<Text style={styles.shareProgressValue}>
|
||||||
{progress.completed} / {progress.target} 天
|
{t('challengeDetail.shareCard.progress.days', { completed: progress.completed, target: progress.target })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -429,8 +515,8 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
<Text style={styles.shareProgressSubtext}>
|
<Text style={styles.shareProgressSubtext}>
|
||||||
{progress.completed === progress.target
|
{progress.completed === progress.target
|
||||||
? '🎉 已完成挑战!'
|
? t('challengeDetail.shareCard.progress.completed')
|
||||||
: `还差 ${progress.target - progress.completed} 天完成挑战`}
|
: t('challengeDetail.shareCard.progress.remaining', { remaining: progress.target - progress.completed })}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -454,7 +540,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.shareInfoTextWrapper}>
|
<View style={styles.shareInfoTextWrapper}>
|
||||||
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
|
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
|
||||||
<Text style={styles.shareInfoMeta}>按日打卡自动累计</Text>
|
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -464,7 +550,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.shareInfoTextWrapper}>
|
<View style={styles.shareInfoTextWrapper}>
|
||||||
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
|
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
|
||||||
<Text style={styles.shareInfoMeta}>快来一起坚持吧</Text>
|
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.joinUs')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -472,7 +558,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
|
|
||||||
{/* 底部标识 */}
|
{/* 底部标识 */}
|
||||||
<View style={styles.shareCardFooter}>
|
<View style={styles.shareCardFooter}>
|
||||||
<Text style={styles.shareCardFooterText}>Out Live · 超越生命</Text>
|
<Text style={styles.shareCardFooterText}>{t('challengeDetail.shareCard.footer')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -568,7 +654,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.detailTextWrapper}>
|
<View style={styles.detailTextWrapper}>
|
||||||
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
|
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
|
||||||
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
<Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -590,19 +676,50 @@ export default function ChallengeDetailScreen() {
|
|||||||
))}
|
))}
|
||||||
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||||||
<TouchableOpacity style={styles.moreAvatarButton}>
|
<TouchableOpacity style={styles.moreAvatarButton}>
|
||||||
<Text style={styles.moreAvatarText}>更多</Text>
|
<Text style={styles.moreAvatarText}>{t('challengeDetail.participants.more')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</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>
|
||||||
|
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>排行榜</Text>
|
<Text style={styles.sectionTitle}>{t('challengeDetail.ranking.title')}</Text>
|
||||||
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||||||
<Text style={styles.sectionAction}>查看全部</Text>
|
<Text style={styles.sectionAction}>{t('challengeDetail.detail.viewAllRanking')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -623,7 +740,7 @@ export default function ChallengeDetailScreen() {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.emptyRanking}>
|
<View style={styles.emptyRanking}>
|
||||||
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
<Text style={styles.emptyRankingText}>{t('challengeDetail.ranking.empty')}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -632,11 +749,30 @@ export default function ChallengeDetailScreen() {
|
|||||||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||||
<View style={styles.floatingCTAContent}>
|
<View style={styles.floatingCTAContent}>
|
||||||
<View style={styles.highlightCopy}>
|
{showShareCode ? (
|
||||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
|
||||||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
<View style={styles.shareCodeRow}>
|
||||||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||||
</View>
|
<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
|
<TouchableOpacity
|
||||||
style={styles.highlightButton}
|
style={styles.highlightButton}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
@@ -732,6 +868,19 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 16,
|
marginRight: 16,
|
||||||
},
|
},
|
||||||
|
highlightCopyCompact: {
|
||||||
|
marginRight: 12,
|
||||||
|
gap: 4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
shareCodeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
headerTextBlock: {
|
headerTextBlock: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
marginTop: HERO_HEIGHT - 60,
|
marginTop: HERO_HEIGHT - 60,
|
||||||
@@ -834,6 +983,49 @@ const styles = StyleSheet.create({
|
|||||||
color: '#4F5BD5',
|
color: '#4F5BD5',
|
||||||
fontWeight: '600',
|
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: {
|
sectionHeader: {
|
||||||
marginTop: 36,
|
marginTop: 36,
|
||||||
marginHorizontal: 24,
|
marginHorizontal: 24,
|
||||||
@@ -889,6 +1081,10 @@ const styles = StyleSheet.create({
|
|||||||
color: '#5f6a97',
|
color: '#5f6a97',
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
shareCodeIconButton: {
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
ctaErrorText: {
|
ctaErrorText: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -1084,4 +1280,3 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
976
app/challenges/create-custom.tsx
Normal file
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2,7 +2,8 @@ import { ThemedView } from '@/components/ThemedView';
|
|||||||
import { ROUTES } from '@/constants/Routes';
|
import { ROUTES } from '@/constants/Routes';
|
||||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
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 { router } from 'expo-router';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ActivityIndicator, View } from 'react-native';
|
import { ActivityIndicator, View } from 'react-native';
|
||||||
@@ -19,10 +20,11 @@ export default function SplashScreen() {
|
|||||||
|
|
||||||
const checkOnboardingStatus = async () => {
|
const checkOnboardingStatus = async () => {
|
||||||
try {
|
try {
|
||||||
// 先预加载用户数据,包括 onboarding 状态
|
// 直接读取 onboarding 状态
|
||||||
console.log('开始预加载用户数据(包含 onboarding 状态)...');
|
console.log('检查 onboarding 状态...');
|
||||||
const userData = await preloadUserData();
|
const onboardingCompletedStr = await AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted);
|
||||||
console.log('用户数据预加载完成,onboarding 状态:', userData.onboardingCompleted);
|
const onboardingCompleted = onboardingCompletedStr === 'true';
|
||||||
|
console.log('Onboarding 状态:', onboardingCompleted);
|
||||||
|
|
||||||
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
|
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
|
||||||
console.log('开始初始化推送通知基础服务...');
|
console.log('开始初始化推送通知基础服务...');
|
||||||
@@ -30,8 +32,8 @@ export default function SplashScreen() {
|
|||||||
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
|
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据预加载的状态决定跳转
|
// 根据状态决定跳转
|
||||||
if (userData.onboardingCompleted) {
|
if (onboardingCompleted) {
|
||||||
console.log('用户已完成引导,跳转到统计页面');
|
console.log('用户已完成引导,跳转到统计页面');
|
||||||
router.replace(ROUTES.TAB_STATISTICS);
|
router.replace(ROUTES.TAB_STATISTICS);
|
||||||
} else {
|
} else {
|
||||||
@@ -39,7 +41,7 @@ export default function SplashScreen() {
|
|||||||
router.replace(ROUTES.ONBOARDING);
|
router.replace(ROUTES.ONBOARDING);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查引导状态或预加载用户数据失败:', error);
|
console.error('检查引导状态失败:', error);
|
||||||
// 如果出现错误,默认进入主应用(假设已完成引导)
|
// 如果出现错误,默认进入主应用(假设已完成引导)
|
||||||
router.replace(ROUTES.TAB_STATISTICS);
|
router.replace(ROUTES.TAB_STATISTICS);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/fonts/ali-bold.ttf
Normal file
BIN
assets/fonts/ali-bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ali-regular.ttf
Normal file
BIN
assets/fonts/ali-regular.ttf
Normal file
Binary file not shown.
@@ -215,11 +215,13 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
},
|
},
|
||||||
remaining: {
|
remaining: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
|
fontFamily: 'AliRegular'
|
||||||
},
|
},
|
||||||
metaRow: {
|
metaRow: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
@@ -227,10 +229,12 @@ const styles = StyleSheet.create({
|
|||||||
metaValue: {
|
metaValue: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
},
|
},
|
||||||
metaSuffix: {
|
metaSuffix: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
fontFamily: 'AliBold'
|
||||||
},
|
},
|
||||||
track: {
|
track: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
Animated,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
KeyboardAvoidingView,
|
||||||
Modal,
|
Modal,
|
||||||
|
Platform,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -24,6 +27,7 @@ interface ConfirmationSheetProps {
|
|||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
content?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmationSheet({
|
export function ConfirmationSheet({
|
||||||
@@ -36,11 +40,13 @@ export function ConfirmationSheet({
|
|||||||
cancelText = '取消',
|
cancelText = '取消',
|
||||||
destructive = false,
|
destructive = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
content,
|
||||||
}: ConfirmationSheetProps) {
|
}: ConfirmationSheetProps) {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const translateY = useRef(new Animated.Value(screenHeight)).current;
|
const translateY = useRef(new Animated.Value(screenHeight)).current;
|
||||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||||
const [modalVisible, setModalVisible] = useState(visible);
|
const [modalVisible, setModalVisible] = useState(visible);
|
||||||
|
const isGlassAvailable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -116,7 +122,10 @@ export function ConfirmationSheet({
|
|||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
style={styles.overlay}
|
||||||
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.backdrop,
|
styles.backdrop,
|
||||||
@@ -140,35 +149,67 @@ export function ConfirmationSheet({
|
|||||||
<View style={styles.handle} />
|
<View style={styles.handle} />
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||||
|
{content}
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.cancelButton}
|
style={[styles.buttonContainer, loading && styles.disabledButton]}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={handleCancel}
|
onPress={handleCancel}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Text style={styles.cancelText}>{cancelText}</Text>
|
{isGlassAvailable ? (
|
||||||
|
<GlassView
|
||||||
|
style={styles.glassButton}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor="rgba(241, 245, 249, 0.6)"
|
||||||
|
isInteractive
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelText}>{cancelText}</Text>
|
||||||
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.cancelButton}>
|
||||||
|
<Text style={styles.cancelText}>{cancelText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[styles.buttonContainer, loading && styles.disabledButton]}
|
||||||
styles.confirmButton,
|
|
||||||
destructive ? styles.destructiveButton : styles.primaryButton,
|
|
||||||
loading && styles.disabledButton,
|
|
||||||
]}
|
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={handleConfirm}
|
onPress={handleConfirm}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{isGlassAvailable ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<GlassView
|
||||||
|
style={styles.glassButton}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
tintColor={destructive ? 'rgba(239, 68, 68, 0.85)' : 'rgba(37, 99, 235, 0.85)'}
|
||||||
|
isInteractive
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||||
|
)}
|
||||||
|
</GlassView>
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.confirmButton,
|
||||||
|
destructive ? styles.destructiveButton : styles.primaryButton,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,8 +262,17 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
buttonContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
},
|
||||||
|
glassButton: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 18,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -237,7 +287,6 @@ const styles = StyleSheet.create({
|
|||||||
color: '#111827',
|
color: '#111827',
|
||||||
},
|
},
|
||||||
confirmButton: {
|
confirmButton: {
|
||||||
flex: 1,
|
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function useAuthGuard() {
|
|||||||
const currentPath = usePathname();
|
const currentPath = usePathname();
|
||||||
const user = useAppSelector(state => state.user);
|
const user = useAppSelector(state => state.user);
|
||||||
|
|
||||||
const isLoggedIn = !!user?.profile?.id;
|
// 判断登录状态:优先使用 token,因为 token 是登录的根本凭证
|
||||||
|
// profile.id 可能在初始化时还未加载,但 token 已经从 AsyncStorage 恢复
|
||||||
|
const isLoggedIn = !!user?.token;
|
||||||
|
|
||||||
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
|
const ensureLoggedIn = useCallback(async (options?: EnsureOptions): Promise<boolean> => {
|
||||||
if (isLoggedIn) return true;
|
if (isLoggedIn) return true;
|
||||||
|
|||||||
278
i18n/index.ts
278
i18n/index.ts
@@ -851,6 +851,222 @@ const medicationsResources = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const challengeDetailResources = {
|
||||||
|
title: '挑战详情',
|
||||||
|
notFound: '未找到该挑战,稍后再试试吧。',
|
||||||
|
loading: '加载挑战详情中…',
|
||||||
|
retry: '重新加载',
|
||||||
|
share: {
|
||||||
|
generating: '正在生成分享卡片...',
|
||||||
|
failed: '分享失败,请稍后重试',
|
||||||
|
messageJoined: '我正在参与「{{title}}」挑战,已完成 {{completed}}/{{target}} 天!一起加入吧!',
|
||||||
|
messageNotJoined: '发现一个很棒的挑战「{{title}}」,一起来参与吧!',
|
||||||
|
},
|
||||||
|
dateRange: {
|
||||||
|
format: '{{start}} - {{end}}',
|
||||||
|
monthDay: '{{month}}月{{day}}日',
|
||||||
|
ongoing: '持续更新中',
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
count: '{{count}} 人正在参与',
|
||||||
|
ongoing: '持续更新中',
|
||||||
|
more: '更多',
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
requirement: '按日打卡自动累计',
|
||||||
|
viewAllRanking: '查看全部',
|
||||||
|
},
|
||||||
|
checkIn: {
|
||||||
|
title: '挑战打卡',
|
||||||
|
todayChecked: '今日已打卡',
|
||||||
|
subtitle: '每日打卡会累计进度,达成目标天数',
|
||||||
|
subtitleChecked: '已记录今日进度,明天继续保持',
|
||||||
|
button: {
|
||||||
|
checkIn: '立即打卡',
|
||||||
|
checking: '打卡中…',
|
||||||
|
checked: '今日已打卡',
|
||||||
|
notJoined: '加入后打卡',
|
||||||
|
upcoming: '挑战未开始',
|
||||||
|
expired: '挑战已结束',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
alreadyChecked: '今日已打卡',
|
||||||
|
notStarted: '挑战未开始,开始后再来打卡',
|
||||||
|
expired: '挑战已结束,无法打卡',
|
||||||
|
mustJoin: '加入挑战后才能打卡',
|
||||||
|
success: '打卡成功,继续坚持!',
|
||||||
|
failed: '打卡失败,请稍后再试',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
join: '立即加入挑战',
|
||||||
|
joining: '加入中…',
|
||||||
|
leave: '退出挑战',
|
||||||
|
leaving: '退出中…',
|
||||||
|
upcoming: '挑战即将开始',
|
||||||
|
expired: '挑战已结束',
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
join: {
|
||||||
|
title: '立即加入挑战',
|
||||||
|
subtitle: '邀请好友一起坚持,更容易收获成果',
|
||||||
|
},
|
||||||
|
leave: {
|
||||||
|
title: '先别急着离开',
|
||||||
|
subtitle: '再坚持一下,下一个里程碑就要出现了',
|
||||||
|
},
|
||||||
|
upcoming: {
|
||||||
|
title: '挑战即将开始',
|
||||||
|
subtitle: '{{date}} 开始,敬请期待',
|
||||||
|
subtitleFallback: '挑战即将开启,敬请期待',
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
title: '挑战已结束',
|
||||||
|
subtitle: '{{date}} 已截止,期待下一次挑战',
|
||||||
|
subtitleFallback: '本轮挑战已结束,期待下一次挑战',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alert: {
|
||||||
|
leaveConfirm: {
|
||||||
|
title: '确认退出挑战?',
|
||||||
|
message: '退出后需要重新加入才能继续坚持。',
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '退出挑战',
|
||||||
|
},
|
||||||
|
joinFailed: '加入挑战失败',
|
||||||
|
leaveFailed: '退出挑战失败',
|
||||||
|
},
|
||||||
|
ranking: {
|
||||||
|
title: '排行榜',
|
||||||
|
description: '',
|
||||||
|
empty: '榜单即将开启,快来抢占席位。',
|
||||||
|
},
|
||||||
|
shareCard: {
|
||||||
|
footer: 'Out Live · 超越生命',
|
||||||
|
progress: {
|
||||||
|
label: '我的坚持进度',
|
||||||
|
days: '{{completed}} / {{target}} 天',
|
||||||
|
completed: '🎉 已完成挑战!',
|
||||||
|
remaining: '还差 {{remaining}} 天完成挑战',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
checkInDaily: '按日打卡',
|
||||||
|
joinUs: '快来一起坚持吧',
|
||||||
|
},
|
||||||
|
shareCode: {
|
||||||
|
copied: '分享码已复制',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const challengeDetailResourcesEn = {
|
||||||
|
title: 'Challenge Details',
|
||||||
|
notFound: 'Challenge not found, please try again later.',
|
||||||
|
loading: 'Loading challenge details…',
|
||||||
|
retry: 'Reload',
|
||||||
|
share: {
|
||||||
|
generating: 'Generating share card...',
|
||||||
|
failed: 'Share failed, please try again later',
|
||||||
|
messageJoined: 'I\'m participating in "{{title}}" challenge, completed {{completed}}/{{target}} days! Join me!',
|
||||||
|
messageNotJoined: 'Found an amazing challenge "{{title}}", let\'s join together!',
|
||||||
|
},
|
||||||
|
dateRange: {
|
||||||
|
format: '{{start}} - {{end}}',
|
||||||
|
monthDay: 'Month {{month}} Day {{day}}',
|
||||||
|
ongoing: 'Ongoing updates',
|
||||||
|
},
|
||||||
|
participants: {
|
||||||
|
count: '{{count}} participants',
|
||||||
|
ongoing: 'Ongoing updates',
|
||||||
|
more: 'More',
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
requirement: 'Daily check-in auto accumulates',
|
||||||
|
viewAllRanking: 'View All',
|
||||||
|
},
|
||||||
|
checkIn: {
|
||||||
|
title: 'Challenge Check-in',
|
||||||
|
todayChecked: 'Checked in today',
|
||||||
|
subtitle: 'Daily check-ins accumulate progress towards goal',
|
||||||
|
subtitleChecked: 'Today\'s progress recorded, keep it up tomorrow',
|
||||||
|
button: {
|
||||||
|
checkIn: 'Check In Now',
|
||||||
|
checking: 'Checking in…',
|
||||||
|
checked: 'Checked in today',
|
||||||
|
notJoined: 'Join to check in',
|
||||||
|
upcoming: 'Not started yet',
|
||||||
|
expired: 'Challenge ended',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
alreadyChecked: 'Already checked in today',
|
||||||
|
notStarted: 'Challenge not started yet, check in after it begins',
|
||||||
|
expired: 'Challenge has ended, cannot check in',
|
||||||
|
mustJoin: 'Join the challenge to check in',
|
||||||
|
success: 'Check-in successful, keep going!',
|
||||||
|
failed: 'Check-in failed, please try again',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
join: 'Join Challenge',
|
||||||
|
joining: 'Joining…',
|
||||||
|
leave: 'Leave Challenge',
|
||||||
|
leaving: 'Leaving…',
|
||||||
|
upcoming: 'Starting Soon',
|
||||||
|
expired: 'Challenge Ended',
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
join: {
|
||||||
|
title: 'Join Challenge Now',
|
||||||
|
subtitle: 'Invite friends to persist together, achieve more easily',
|
||||||
|
},
|
||||||
|
leave: {
|
||||||
|
title: 'Don\'t leave just yet',
|
||||||
|
subtitle: 'Keep going, the next milestone is around the corner',
|
||||||
|
},
|
||||||
|
upcoming: {
|
||||||
|
title: 'Challenge Starting Soon',
|
||||||
|
subtitle: 'Starts on {{date}}, stay tuned',
|
||||||
|
subtitleFallback: 'Challenge coming soon, stay tuned',
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
title: 'Challenge Ended',
|
||||||
|
subtitle: 'Ended on {{date}}, look forward to the next one',
|
||||||
|
subtitleFallback: 'This round has ended, look forward to the next challenge',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alert: {
|
||||||
|
leaveConfirm: {
|
||||||
|
title: 'Confirm leaving challenge?',
|
||||||
|
message: 'You will need to rejoin to continue.',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirm: 'Leave Challenge',
|
||||||
|
},
|
||||||
|
joinFailed: 'Failed to join challenge',
|
||||||
|
leaveFailed: 'Failed to leave challenge',
|
||||||
|
},
|
||||||
|
ranking: {
|
||||||
|
title: 'Leaderboard',
|
||||||
|
description: '',
|
||||||
|
empty: 'Leaderboard opening soon, grab your spot.',
|
||||||
|
},
|
||||||
|
shareCard: {
|
||||||
|
footer: 'Out Live · Beyond Life',
|
||||||
|
progress: {
|
||||||
|
label: 'My Progress',
|
||||||
|
days: '{{completed}} / {{target}} days',
|
||||||
|
completed: '🎉 Challenge Completed!',
|
||||||
|
remaining: '{{remaining}} days to complete',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
checkInDaily: 'Daily check-in',
|
||||||
|
joinUs: 'Join us!',
|
||||||
|
},
|
||||||
|
shareCode: {
|
||||||
|
copied: 'Share code copied',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const notificationSettingsResources = {
|
const notificationSettingsResources = {
|
||||||
title: '通知设置',
|
title: '通知设置',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
@@ -926,6 +1142,37 @@ const resources = {
|
|||||||
statistics: statisticsResources,
|
statistics: statisticsResources,
|
||||||
medications: medicationsResources,
|
medications: medicationsResources,
|
||||||
notificationSettings: notificationSettingsResources,
|
notificationSettings: notificationSettingsResources,
|
||||||
|
challengeDetail: challengeDetailResources,
|
||||||
|
challenges: {
|
||||||
|
title: '挑战',
|
||||||
|
subtitle: '参与精选活动,保持每日动力',
|
||||||
|
loading: '加载挑战中…',
|
||||||
|
loadFailed: '加载挑战失败,请稍后重试',
|
||||||
|
retry: '重新加载',
|
||||||
|
empty: '暂无挑战,稍后再来探索。',
|
||||||
|
customChallenges: '自定义挑战',
|
||||||
|
officialChallenges: '暂无官方挑战,稍后再来探索。',
|
||||||
|
officialChallengesTitle: '官方挑战',
|
||||||
|
join: '加入',
|
||||||
|
create: '创建',
|
||||||
|
joined: '已加入',
|
||||||
|
invalidInviteCode: '请输入有效的邀请码',
|
||||||
|
joinSuccess: '加入挑战成功',
|
||||||
|
joinFailed: '加入失败,请稍后再试',
|
||||||
|
joinModal: {
|
||||||
|
title: '加入自定义挑战',
|
||||||
|
description: '输入 6-12 位邀请码,加入好友的挑战',
|
||||||
|
placeholder: '如:A3K9P2',
|
||||||
|
confirm: '确认加入',
|
||||||
|
cancel: '取消',
|
||||||
|
joining: '加入中…',
|
||||||
|
},
|
||||||
|
statusLabels: {
|
||||||
|
upcoming: '即将开始',
|
||||||
|
ongoing: '进行中',
|
||||||
|
expired: '已结束',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -1753,6 +2000,37 @@ const resources = {
|
|||||||
},
|
},
|
||||||
resetSuccess: 'Settings reset to default',
|
resetSuccess: 'Settings reset to default',
|
||||||
},
|
},
|
||||||
|
challengeDetail: challengeDetailResourcesEn,
|
||||||
|
challenges: {
|
||||||
|
title: 'Challenges',
|
||||||
|
subtitle: 'Join curated activities, stay motivated daily',
|
||||||
|
loading: 'Loading challenges…',
|
||||||
|
loadFailed: 'Failed to load challenges, please try again later',
|
||||||
|
retry: 'Retry',
|
||||||
|
empty: 'No challenges available, check back later.',
|
||||||
|
customChallenges: 'Custom Challenges',
|
||||||
|
officialChallenges: 'No official challenges available, check back later.',
|
||||||
|
officialChallengesTitle: 'Official Challenges',
|
||||||
|
join: 'Join',
|
||||||
|
create: 'Create',
|
||||||
|
joined: 'Joined',
|
||||||
|
invalidInviteCode: 'Please enter a valid invite code',
|
||||||
|
joinSuccess: 'Successfully joined challenge',
|
||||||
|
joinFailed: 'Failed to join challenge, please try again later',
|
||||||
|
joinModal: {
|
||||||
|
title: 'Join Custom Challenge',
|
||||||
|
description: 'Enter 6-12 digit invite code to join friend\'s challenge',
|
||||||
|
placeholder: 'e.g., A3K9P2',
|
||||||
|
confirm: 'Confirm Join',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
joining: 'Joining…',
|
||||||
|
},
|
||||||
|
statusLabels: {
|
||||||
|
upcoming: 'Upcoming',
|
||||||
|
ongoing: 'Ongoing',
|
||||||
|
expired: 'Expired',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 70;
|
objectVersion = 60;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
79E80BBB2EC5D92B004425BE /* Exceptions for "medicine" folder in "medicineExtension" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
@@ -101,7 +101,18 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
79E80BA72EC5D92A004425BE /* medicine */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (79E80BBB2EC5D92B004425BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = medicine; sourceTree = "<group>"; };
|
79E80BA72EC5D92A004425BE /* medicine */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
79E80BBB2EC5D92B004425BE /* Exceptions for "medicine" folder in "medicineExtension" target */,
|
||||||
|
);
|
||||||
|
explicitFileTypes = {
|
||||||
|
};
|
||||||
|
explicitFolders = (
|
||||||
|
);
|
||||||
|
path = medicine;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.30</string>
|
<string>1.1.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ PODS:
|
|||||||
- React-Core
|
- React-Core
|
||||||
- EXNotifications (0.32.12):
|
- EXNotifications (0.32.12):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- Expo (54.0.21):
|
- Expo (54.0.25):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -35,25 +35,27 @@ PODS:
|
|||||||
- Yoga
|
- Yoga
|
||||||
- ExpoAppleAuthentication (8.0.7):
|
- ExpoAppleAuthentication (8.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoAsset (12.0.9):
|
- ExpoAsset (12.0.10):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoBackgroundTask (1.0.8):
|
- ExpoBackgroundTask (1.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoBlur (15.0.7):
|
- ExpoBlur (15.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoCamera (17.0.8):
|
- ExpoCamera (17.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ZXingObjC/OneD
|
- ZXingObjC/OneD
|
||||||
- ZXingObjC/PDF417
|
- ZXingObjC/PDF417
|
||||||
- ExpoFileSystem (19.0.17):
|
- ExpoClipboard (8.0.7):
|
||||||
|
- ExpoModulesCore
|
||||||
|
- ExpoFileSystem (19.0.19):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoFont (14.0.9):
|
- ExpoFont (14.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoGlassEffect (0.1.5):
|
- ExpoGlassEffect (0.1.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHaptics (15.0.7):
|
- ExpoHaptics (15.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHead (6.0.14):
|
- ExpoHead (6.0.15):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- RNScreens
|
- RNScreens
|
||||||
- ExpoImage (3.0.10):
|
- ExpoImage (3.0.10):
|
||||||
@@ -69,14 +71,14 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoLinearGradient (15.0.7):
|
- ExpoLinearGradient (15.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoLinking (8.0.8):
|
- ExpoLinking (8.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoLocalization (17.0.7):
|
- ExpoLocalization (17.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoMediaLibrary (18.2.0):
|
- ExpoMediaLibrary (18.2.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- React-Core
|
- React-Core
|
||||||
- ExpoModulesCore (3.0.23):
|
- ExpoModulesCore (3.0.26):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -101,7 +103,7 @@ PODS:
|
|||||||
- Yoga
|
- Yoga
|
||||||
- ExpoQuickActions (6.0.0):
|
- ExpoQuickActions (6.0.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSplashScreen (31.0.10):
|
- ExpoSplashScreen (31.0.11):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSQLite (16.0.8):
|
- ExpoSQLite (16.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -161,8 +163,8 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- PurchasesHybridCommon (17.10.0):
|
- PurchasesHybridCommon (17.19.1):
|
||||||
- RevenueCat (= 5.43.0)
|
- RevenueCat (= 5.48.0)
|
||||||
- RCTDeprecation (0.81.5)
|
- RCTDeprecation (0.81.5)
|
||||||
- RCTRequired (0.81.5)
|
- RCTRequired (0.81.5)
|
||||||
- RCTTypeSafety (0.81.5):
|
- RCTTypeSafety (0.81.5):
|
||||||
@@ -1909,7 +1911,7 @@ PODS:
|
|||||||
- React-utils (= 0.81.5)
|
- React-utils (= 0.81.5)
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- ReactNativeDependencies (0.81.5)
|
- ReactNativeDependencies (0.81.5)
|
||||||
- RevenueCat (5.43.0)
|
- RevenueCat (5.48.0)
|
||||||
- RNCAsyncStorage (2.2.0):
|
- RNCAsyncStorage (2.2.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -1954,7 +1956,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNCPicker (2.11.1):
|
- RNCPicker (2.11.4):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -1976,7 +1978,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNDateTimePicker (8.4.4):
|
- RNDateTimePicker (8.5.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2022,10 +2024,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNPurchases (9.5.4):
|
- RNPurchases (9.6.7):
|
||||||
- PurchasesHybridCommon (= 17.10.0)
|
- PurchasesHybridCommon (= 17.19.1)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReanimated (4.1.3):
|
- RNReanimated (4.1.5):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2047,10 +2049,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated (= 4.1.3)
|
- RNReanimated/reanimated (= 4.1.5)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated (4.1.3):
|
- RNReanimated/reanimated (4.1.5):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2072,10 +2074,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated/apple (= 4.1.3)
|
- RNReanimated/reanimated/apple (= 4.1.5)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated/apple (4.1.3):
|
- RNReanimated/reanimated/apple (4.1.5):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2146,7 +2148,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNSentry (7.2.0):
|
- RNSentry (7.7.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2168,7 +2170,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Sentry/HybridSDK (= 8.56.1)
|
- Sentry/HybridSDK (= 8.57.3)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNSVG (15.12.1):
|
- RNSVG (15.12.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
@@ -2297,7 +2299,7 @@ PODS:
|
|||||||
- SDWebImageWebPCoder (0.14.6):
|
- SDWebImageWebPCoder (0.14.6):
|
||||||
- libwebp (~> 1.0)
|
- libwebp (~> 1.0)
|
||||||
- SDWebImage/Core (~> 5.17)
|
- SDWebImage/Core (~> 5.17)
|
||||||
- Sentry/HybridSDK (8.56.1)
|
- Sentry/HybridSDK (8.57.3)
|
||||||
- UMAppLoader (6.0.7)
|
- UMAppLoader (6.0.7)
|
||||||
- Yoga (0.0.0)
|
- Yoga (0.0.0)
|
||||||
- ZXingObjC/Core (3.6.9)
|
- ZXingObjC/Core (3.6.9)
|
||||||
@@ -2317,6 +2319,7 @@ DEPENDENCIES:
|
|||||||
- ExpoBackgroundTask (from `../node_modules/expo-background-task/ios`)
|
- ExpoBackgroundTask (from `../node_modules/expo-background-task/ios`)
|
||||||
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
- ExpoBlur (from `../node_modules/expo-blur/ios`)
|
||||||
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
- ExpoCamera (from `../node_modules/expo-camera/ios`)
|
||||||
|
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`)
|
||||||
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
|
||||||
- ExpoFont (from `../node_modules/expo-font/ios`)
|
- ExpoFont (from `../node_modules/expo-font/ios`)
|
||||||
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
|
- ExpoGlassEffect (from `../node_modules/expo-glass-effect/ios`)
|
||||||
@@ -2462,6 +2465,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../node_modules/expo-blur/ios"
|
:path: "../node_modules/expo-blur/ios"
|
||||||
ExpoCamera:
|
ExpoCamera:
|
||||||
:path: "../node_modules/expo-camera/ios"
|
:path: "../node_modules/expo-camera/ios"
|
||||||
|
ExpoClipboard:
|
||||||
|
:path: "../node_modules/expo-clipboard/ios"
|
||||||
ExpoFileSystem:
|
ExpoFileSystem:
|
||||||
:path: "../node_modules/expo-file-system/ios"
|
:path: "../node_modules/expo-file-system/ios"
|
||||||
ExpoFont:
|
ExpoFont:
|
||||||
@@ -2683,27 +2688,28 @@ SPEC CHECKSUMS:
|
|||||||
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
EXConstants: fd688cef4e401dcf798a021cfb5d87c890c30ba3
|
||||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||||
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
|
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
|
||||||
Expo: 27ae59be9be4feab2b1c1ae06550752c524ca558
|
Expo: 111394d38f32be09385d4c7f70cc96d2da438d0d
|
||||||
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
||||||
ExpoAsset: 9ba6fbd677fb8e241a3899ac00fa735bc911eadf
|
ExpoAsset: d839c8eae8124470332408427327e8f88beb2dfd
|
||||||
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
|
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
|
||||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||||
ExpoCamera: e75f6807a2c047f3338bbadd101af4c71a1d13a5
|
ExpoCamera: 2a87c210f8955350ea5c70f1d539520b2fc5d940
|
||||||
ExpoFileSystem: b79eadbda7b7f285f378f95f959cc9313a1c9c61
|
ExpoClipboard: af650d14765f19c60ce2a1eaf9dfe6445eff7365
|
||||||
|
ExpoFileSystem: 77157a101e03150a4ea4f854b4dd44883c93ae0a
|
||||||
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
||||||
ExpoGlassEffect: 779c46bd04ea47ba4726efb73267b5bcc6abd664
|
ExpoGlassEffect: 265fa3d75b46bc58262e4dfa513135fa9dfe4aac
|
||||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||||
ExpoHead: e317214fa14edeaf17748d39ec9e550a3d1194fb
|
ExpoHead: 95a6ee0be1142320bccf07961d6a1502ded5d6ac
|
||||||
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
|
ExpoImage: 9c3428921c536ab29e5c6721d001ad5c1f469566
|
||||||
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
||||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||||
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
||||||
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
|
ExpoLinking: 77455aa013e9b6a3601de03ecfab09858ee1b031
|
||||||
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
ExpoLocalization: b852a5d8ec14c5349c1593eca87896b5b3ebfcca
|
||||||
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
|
ExpoMediaLibrary: 641a6952299b395159ccd459bd8f5f6764bf55fe
|
||||||
ExpoModulesCore: 5f20603cf25698682d7c43c05fbba8c748b189d2
|
ExpoModulesCore: e8ec7f8727caf51a49d495598303dd420ca994bf
|
||||||
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
||||||
ExpoSplashScreen: cbb839de72110dea1851dd3e85080b7923af2540
|
ExpoSplashScreen: 268b2f128dc04284c21010540a6c4dd9f95003e3
|
||||||
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
||||||
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
||||||
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
ExpoSystemUI: 2761aa6875849af83286364811d46e8ed8ea64c7
|
||||||
@@ -2717,7 +2723,7 @@ SPEC CHECKSUMS:
|
|||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||||
PurchasesHybridCommon: b7b4eafb55fbaaac19b4c36d4082657a3f0d8490
|
PurchasesHybridCommon: a4837eebc889b973668af685d6c23b89a038461d
|
||||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||||
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
|
RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043
|
||||||
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c
|
||||||
@@ -2787,24 +2793,24 @@ SPEC CHECKSUMS:
|
|||||||
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc
|
||||||
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8
|
||||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||||
RevenueCat: a51003d4cb33820cc504cf177c627832b462a98e
|
RevenueCat: 1e61140a343a77dc286f171b3ffab99ca09a4b57
|
||||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||||
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
||||||
RNCPicker: a7170edbcbf8288de8edb2502e08e7fc757fa755
|
RNCPicker: c8a3584b74133464ee926224463fcc54dfdaebca
|
||||||
RNDateTimePicker: be0e44bcb9ed0607c7c5f47dbedd88cf091f6791
|
RNDateTimePicker: 19ffa303c4524ec0a2dfdee2658198451c16b7f1
|
||||||
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||||
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
||||||
RNPurchases: 2569675abdc1dbc739f2eec0fa564a112cf860de
|
RNPurchases: 5f3cd4fea5ef2b3914c925b2201dd5cecd31922f
|
||||||
RNReanimated: 3895a29fdf77bbe2a627e1ed599a5e5d1df76c29
|
RNReanimated: 1442a577e066e662f0ce1cd1864a65c8e547aee0
|
||||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||||
RNSentry: 41979b419908128847ef662cc130a400b7576fa9
|
RNSentry: 1d7b9fdae7a01ad8f9053335b5d44e75c39a955e
|
||||||
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
RNSVG: 31d6639663c249b7d5abc9728dde2041eb2a3c34
|
||||||
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
|
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
|
||||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||||
Sentry: b3ec44d01708fce73f99b544beb57e890eca4406
|
Sentry: c643eb180df401dd8c734c5036ddd9dd9218daa6
|
||||||
UMAppLoader: e1234c45d2b7da239e9e90fc4bbeacee12afd5b6
|
UMAppLoader: e1234c45d2b7da239e9e90fc4bbeacee12afd5b6
|
||||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||||
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
|
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
|
||||||
|
|||||||
747
package-lock.json
generated
747
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -11,27 +11,28 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@expo/ui": "~0.2.0-beta.7",
|
"@expo/ui": "~0.2.0-beta.7",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-native-community/datetimepicker": "8.4.4",
|
"@react-native-community/datetimepicker": "8.5.1",
|
||||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||||
"@react-native-picker/picker": "2.11.1",
|
"@react-native-picker/picker": "2.11.4",
|
||||||
"@react-native-voice/voice": "^3.2.4",
|
"@react-native-voice/voice": "^3.2.4",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.8.6",
|
||||||
"@react-navigation/elements": "^2.6.4",
|
"@react-navigation/elements": "^2.8.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.21",
|
||||||
"@reduxjs/toolkit": "^2.9.0",
|
"@reduxjs/toolkit": "^2.11.0",
|
||||||
"@sentry/react-native": "~7.2.0",
|
"@sentry/react-native": "~7.7.0",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.21",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.19",
|
||||||
"expo": "54.0.21",
|
"expo": "54.0.25",
|
||||||
"expo-apple-authentication": "~8.0.7",
|
"expo-apple-authentication": "~8.0.7",
|
||||||
"expo-background-task": "~1.0.8",
|
"expo-background-task": "~1.0.8",
|
||||||
"expo-blur": "~15.0.7",
|
"expo-blur": "~15.0.7",
|
||||||
"expo-camera": "~17.0.8",
|
"expo-clipboard": "~8.0.7",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-camera": "~17.0.9",
|
||||||
|
"expo-constants": "~18.0.10",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-glass-effect": "~0.1.5",
|
"expo-glass-effect": "~0.1.7",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-image-picker": "~17.0.8",
|
"expo-image-picker": "~17.0.8",
|
||||||
@@ -41,8 +42,8 @@
|
|||||||
"expo-media-library": "^18.2.0",
|
"expo-media-library": "^18.2.0",
|
||||||
"expo-notifications": "~0.32.12",
|
"expo-notifications": "~0.32.12",
|
||||||
"expo-quick-actions": "^6.0.0",
|
"expo-quick-actions": "^6.0.0",
|
||||||
"expo-router": "~6.0.14",
|
"expo-router": "~6.0.15",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.11",
|
||||||
"expo-sqlite": "^16.0.8",
|
"expo-sqlite": "^16.0.8",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
@@ -84,4 +85,4 @@
|
|||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
@@ -82,14 +82,38 @@ async function handle401Unauthorized() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token 缓存:内存中保存一份,避免每次都读取 AsyncStorage
|
||||||
let inMemoryToken: string | null = null;
|
let inMemoryToken: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置认证 token
|
||||||
|
* 同时更新内存缓存和持久化存储
|
||||||
|
*/
|
||||||
export async function setAuthToken(token: string | null): Promise<void> {
|
export async function setAuthToken(token: string | null): Promise<void> {
|
||||||
inMemoryToken = token;
|
inMemoryToken = token;
|
||||||
|
|
||||||
|
// 同步更新 AsyncStorage
|
||||||
|
if (token) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEYS.authToken, token);
|
||||||
|
} else {
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEYS.authToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthToken(): Promise<string | null> {
|
/**
|
||||||
return AsyncStorage.getItem(STORAGE_KEYS.authToken);
|
* 获取认证 token
|
||||||
|
* 优先使用内存缓存,若无则从 AsyncStorage 读取并缓存
|
||||||
|
*/
|
||||||
|
export async function getAuthToken(): Promise<string | null> {
|
||||||
|
// 如果内存中有,直接返回
|
||||||
|
if (inMemoryToken !== null) {
|
||||||
|
return inMemoryToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则从 AsyncStorage 读取并缓存到内存
|
||||||
|
const token = await AsyncStorage.getItem(STORAGE_KEYS.authToken);
|
||||||
|
inMemoryToken = token;
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiRequestOptions = {
|
export type ApiRequestOptions = {
|
||||||
|
|||||||
@@ -2,11 +2,24 @@ import { api } from './api';
|
|||||||
|
|
||||||
export type ChallengeStatus = 'upcoming' | 'ongoing' | 'expired';
|
export type ChallengeStatus = 'upcoming' | 'ongoing' | 'expired';
|
||||||
|
|
||||||
|
export enum ChallengeSource {
|
||||||
|
SYSTEM = 'system',
|
||||||
|
CUSTOM = 'custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChallengeState {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
ACTIVE = 'active',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
export type ChallengeProgressDto = {
|
export type ChallengeProgressDto = {
|
||||||
completed: number;
|
completed: number;
|
||||||
target: number;
|
target: number;
|
||||||
remaining: number
|
remaining: number
|
||||||
checkedInToday: boolean;
|
checkedInToday: boolean;
|
||||||
|
lastProgressAt?: string;
|
||||||
|
last_progress_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RankingItemDto = {
|
export type RankingItemDto = {
|
||||||
@@ -38,7 +51,7 @@ export type ChallengeListItemDto = {
|
|||||||
durationLabel: string;
|
durationLabel: string;
|
||||||
requirementLabel: string;
|
requirementLabel: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
status: ChallengeStatus;
|
status?: ChallengeStatus;
|
||||||
participantsCount: number;
|
participantsCount: number;
|
||||||
rankingDescription?: string;
|
rankingDescription?: string;
|
||||||
highlightTitle: string;
|
highlightTitle: string;
|
||||||
@@ -50,12 +63,23 @@ export type ChallengeListItemDto = {
|
|||||||
endAt?: string;
|
endAt?: string;
|
||||||
minimumCheckInDays: number; // 最小打卡天数
|
minimumCheckInDays: number; // 最小打卡天数
|
||||||
type: ChallengeType;
|
type: ChallengeType;
|
||||||
|
shareCode?: string | null;
|
||||||
|
source?: ChallengeSource;
|
||||||
|
creatorId?: string | null;
|
||||||
|
isCreator?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
maxParticipants?: number | null;
|
||||||
|
challengeState?: ChallengeState;
|
||||||
|
progressUnit?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
summary?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChallengeDetailDto = ChallengeListItemDto & {
|
export type ChallengeDetailDto = ChallengeListItemDto & {
|
||||||
summary?: string;
|
summary?: string | null;
|
||||||
rankings: RankingItemDto[];
|
rankings?: RankingItemDto[];
|
||||||
userRank?: number;
|
userRank?: number;
|
||||||
|
challengeState?: ChallengeState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChallengeRankingsDto = {
|
export type ChallengeRankingsDto = {
|
||||||
@@ -65,6 +89,31 @@ export type ChallengeRankingsDto = {
|
|||||||
items: RankingItemDto[];
|
items: RankingItemDto[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChallengeListResponse = {
|
||||||
|
items: ChallengeListItemDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCustomChallengePayload = {
|
||||||
|
title: string;
|
||||||
|
type: ChallengeType;
|
||||||
|
image?: string | null;
|
||||||
|
startAt: number;
|
||||||
|
endAt: number;
|
||||||
|
targetValue: number;
|
||||||
|
minimumCheckInDays: number;
|
||||||
|
durationLabel: string;
|
||||||
|
requirementLabel: string;
|
||||||
|
summary?: string | null;
|
||||||
|
progressUnit?: string;
|
||||||
|
periodLabel?: string | null;
|
||||||
|
rankingDescription?: string | null;
|
||||||
|
isPublic?: boolean;
|
||||||
|
maxParticipants?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function listChallenges(): Promise<ChallengeListItemDto[]> {
|
export async function listChallenges(): Promise<ChallengeListItemDto[]> {
|
||||||
return api.get<ChallengeListItemDto[]>('/challenges');
|
return api.get<ChallengeListItemDto[]>('/challenges');
|
||||||
}
|
}
|
||||||
@@ -101,3 +150,43 @@ export async function getChallengeRankings(
|
|||||||
const url = `/challenges/${encodeURIComponent(id)}/rankings${query ? `?${query}` : ''}`;
|
const url = `/challenges/${encodeURIComponent(id)}/rankings${query ? `?${query}` : ''}`;
|
||||||
return api.get<ChallengeRankingsDto>(url);
|
return api.get<ChallengeRankingsDto>(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listMyCustomChallenges(
|
||||||
|
params?: { page?: number; pageSize?: number; state?: ChallengeState }
|
||||||
|
): Promise<ChallengeListResponse> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.page) {
|
||||||
|
searchParams.append('page', String(params.page));
|
||||||
|
}
|
||||||
|
if (params?.pageSize) {
|
||||||
|
searchParams.append('pageSize', String(params.pageSize));
|
||||||
|
}
|
||||||
|
if (params?.state) {
|
||||||
|
searchParams.append('state', params.state);
|
||||||
|
}
|
||||||
|
const query = searchParams.toString();
|
||||||
|
const url = `/challenges/my/created${query ? `?${query}` : ''}`;
|
||||||
|
return api.get<ChallengeListResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomChallenge(
|
||||||
|
payload: CreateCustomChallengePayload
|
||||||
|
): Promise<ChallengeDetailDto> {
|
||||||
|
return api.post<ChallengeDetailDto>('/challenges/custom', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function joinChallengeByCode(shareCode: string): Promise<ChallengeProgressDto> {
|
||||||
|
return api.post<ChallengeProgressDto>('/challenges/join-by-code', { shareCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChallengeByShareCode(shareCode: string): Promise<ChallengeDetailDto> {
|
||||||
|
return api.get<ChallengeDetailDto>(`/challenges/share/${encodeURIComponent(shareCode)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateChallengeShareCode(
|
||||||
|
id: string
|
||||||
|
): Promise<{ shareCode: string }> {
|
||||||
|
return api.post<{ shareCode: string }>(
|
||||||
|
`/challenges/custom/${encodeURIComponent(id)}/regenerate-code`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||||
import {
|
import {
|
||||||
type ChallengeDetailDto,
|
type ChallengeDetailDto,
|
||||||
type ChallengeListItemDto,
|
type ChallengeListItemDto,
|
||||||
type ChallengeProgressDto,
|
type ChallengeProgressDto,
|
||||||
|
ChallengeSource,
|
||||||
|
ChallengeState,
|
||||||
type ChallengeStatus,
|
type ChallengeStatus,
|
||||||
|
type CreateCustomChallengePayload,
|
||||||
type RankingItemDto,
|
type RankingItemDto,
|
||||||
|
createCustomChallenge,
|
||||||
|
getChallengeByShareCode,
|
||||||
getChallengeDetail,
|
getChallengeDetail,
|
||||||
getChallengeRankings,
|
getChallengeRankings,
|
||||||
joinChallenge as joinChallengeApi,
|
joinChallenge as joinChallengeApi,
|
||||||
|
joinChallengeByCode as joinChallengeByCodeApi,
|
||||||
leaveChallenge as leaveChallengeApi,
|
leaveChallenge as leaveChallengeApi,
|
||||||
listChallenges,
|
listChallenges,
|
||||||
reportChallengeProgress as reportChallengeProgressApi,
|
reportChallengeProgress as reportChallengeProgressApi,
|
||||||
@@ -21,9 +29,9 @@ export type ChallengeProgress = ChallengeProgressDto;
|
|||||||
export type RankingItem = RankingItemDto;
|
export type RankingItem = RankingItemDto;
|
||||||
export type ChallengeSummary = ChallengeListItemDto;
|
export type ChallengeSummary = ChallengeListItemDto;
|
||||||
export type ChallengeDetail = ChallengeDetailDto;
|
export type ChallengeDetail = ChallengeDetailDto;
|
||||||
export type { ChallengeStatus };
|
export type { ChallengeSource, ChallengeState, ChallengeStatus };
|
||||||
export type ChallengeEntity = ChallengeSummary & {
|
export type ChallengeEntity = ChallengeSummary & {
|
||||||
summary?: string;
|
summary?: string | null;
|
||||||
rankings?: RankingItem[];
|
rankings?: RankingItem[];
|
||||||
userRank?: number;
|
userRank?: number;
|
||||||
};
|
};
|
||||||
@@ -38,7 +46,7 @@ type ChallengeRankingList = {
|
|||||||
|
|
||||||
type ChallengesState = {
|
type ChallengesState = {
|
||||||
entities: Record<string, ChallengeEntity>;
|
entities: Record<string, ChallengeEntity>;
|
||||||
order: string[];
|
orderedIds: string[];
|
||||||
listStatus: AsyncStatus;
|
listStatus: AsyncStatus;
|
||||||
listError?: string;
|
listError?: string;
|
||||||
detailStatus: Record<string, AsyncStatus>;
|
detailStatus: Record<string, AsyncStatus>;
|
||||||
@@ -53,11 +61,15 @@ type ChallengesState = {
|
|||||||
rankingStatus: Record<string, AsyncStatus>;
|
rankingStatus: Record<string, AsyncStatus>;
|
||||||
rankingLoadMoreStatus: Record<string, AsyncStatus>;
|
rankingLoadMoreStatus: Record<string, AsyncStatus>;
|
||||||
rankingError: Record<string, string | undefined>;
|
rankingError: Record<string, string | undefined>;
|
||||||
|
createStatus: AsyncStatus;
|
||||||
|
createError?: string;
|
||||||
|
joinByCodeStatus: AsyncStatus;
|
||||||
|
joinByCodeError?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: ChallengesState = {
|
const initialState: ChallengesState = {
|
||||||
entities: {},
|
entities: {},
|
||||||
order: [],
|
orderedIds: [],
|
||||||
listStatus: 'idle',
|
listStatus: 'idle',
|
||||||
listError: undefined,
|
listError: undefined,
|
||||||
detailStatus: {},
|
detailStatus: {},
|
||||||
@@ -72,6 +84,10 @@ const initialState: ChallengesState = {
|
|||||||
rankingStatus: {},
|
rankingStatus: {},
|
||||||
rankingLoadMoreStatus: {},
|
rankingLoadMoreStatus: {},
|
||||||
rankingError: {},
|
rankingError: {},
|
||||||
|
createStatus: 'idle',
|
||||||
|
createError: undefined,
|
||||||
|
joinByCodeStatus: 'idle',
|
||||||
|
joinByCodeError: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toErrorMessage = (error: unknown): string => {
|
const toErrorMessage = (error: unknown): string => {
|
||||||
@@ -168,10 +184,41 @@ export const fetchChallengeRankings = createAsyncThunk<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createCustomChallengeThunk = createAsyncThunk<
|
||||||
|
ChallengeDetail,
|
||||||
|
CreateCustomChallengePayload,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('challenges/createCustom', async (payload, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
return await createCustomChallenge(payload);
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const joinChallengeByCode = createAsyncThunk<
|
||||||
|
{ challenge: ChallengeDetail; progress: ChallengeProgress },
|
||||||
|
string,
|
||||||
|
{ rejectValue: string }
|
||||||
|
>('challenges/joinByCode', async (shareCode, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const progress = await joinChallengeByCodeApi(shareCode);
|
||||||
|
const challenge = await getChallengeByShareCode(shareCode);
|
||||||
|
return { challenge: { ...challenge, progress }, progress };
|
||||||
|
} catch (error) {
|
||||||
|
return rejectWithValue(toErrorMessage(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const challengesSlice = createSlice({
|
const challengesSlice = createSlice({
|
||||||
name: 'challenges',
|
name: 'challenges',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {},
|
reducers: {
|
||||||
|
resetJoinByCodeState: (state) => {
|
||||||
|
state.joinByCodeStatus = 'idle';
|
||||||
|
state.joinByCodeError = undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
.addCase(fetchChallenges.pending, (state) => {
|
.addCase(fetchChallenges.pending, (state) => {
|
||||||
@@ -181,18 +228,15 @@ const challengesSlice = createSlice({
|
|||||||
.addCase(fetchChallenges.fulfilled, (state, action) => {
|
.addCase(fetchChallenges.fulfilled, (state, action) => {
|
||||||
state.listStatus = 'succeeded';
|
state.listStatus = 'succeeded';
|
||||||
state.listError = undefined;
|
state.listError = undefined;
|
||||||
const ids = new Set<string>();
|
const incomingIds = new Set<string>();
|
||||||
action.payload.forEach((challenge) => {
|
action.payload.forEach((challenge) => {
|
||||||
ids.add(challenge.id);
|
incomingIds.add(challenge.id);
|
||||||
|
const source = challenge.source ?? ChallengeSource.SYSTEM;
|
||||||
const existing = state.entities[challenge.id];
|
const existing = state.entities[challenge.id];
|
||||||
if (existing) {
|
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
|
||||||
Object.assign(existing, challenge);
|
|
||||||
} else {
|
|
||||||
state.entities[challenge.id] = { ...challenge };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
Object.keys(state.entities).forEach((id) => {
|
Object.keys(state.entities).forEach((id) => {
|
||||||
if (!ids.has(id)) {
|
if (!incomingIds.has(id) && !state.entities[id]?.isJoined) {
|
||||||
delete state.entities[id];
|
delete state.entities[id];
|
||||||
delete state.detailStatus[id];
|
delete state.detailStatus[id];
|
||||||
delete state.detailError[id];
|
delete state.detailError[id];
|
||||||
@@ -204,7 +248,7 @@ const challengesSlice = createSlice({
|
|||||||
delete state.progressError[id];
|
delete state.progressError[id];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
state.order = action.payload.map((item) => item.id);
|
state.orderedIds = action.payload.map((item) => item.id);
|
||||||
})
|
})
|
||||||
.addCase(fetchChallenges.rejected, (state, action) => {
|
.addCase(fetchChallenges.rejected, (state, action) => {
|
||||||
state.listStatus = 'failed';
|
state.listStatus = 'failed';
|
||||||
@@ -220,11 +264,8 @@ const challengesSlice = createSlice({
|
|||||||
state.detailStatus[detail.id] = 'succeeded';
|
state.detailStatus[detail.id] = 'succeeded';
|
||||||
state.detailError[detail.id] = undefined;
|
state.detailError[detail.id] = undefined;
|
||||||
const existing = state.entities[detail.id];
|
const existing = state.entities[detail.id];
|
||||||
if (existing) {
|
const source = detail.source ?? existing?.source ?? ChallengeSource.SYSTEM;
|
||||||
Object.assign(existing, detail);
|
state.entities[detail.id] = { ...(existing ?? {}), ...detail, source };
|
||||||
} else {
|
|
||||||
state.entities[detail.id] = { ...detail };
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.addCase(fetchChallengeDetail.rejected, (state, action) => {
|
.addCase(fetchChallengeDetail.rejected, (state, action) => {
|
||||||
const id = action.meta.arg;
|
const id = action.meta.arg;
|
||||||
@@ -333,9 +374,50 @@ const challengesSlice = createSlice({
|
|||||||
state.rankingError[id] = message;
|
state.rankingError[id] = message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder
|
||||||
|
.addCase(createCustomChallengeThunk.pending, (state) => {
|
||||||
|
state.createStatus = 'loading';
|
||||||
|
state.createError = undefined;
|
||||||
|
})
|
||||||
|
.addCase(createCustomChallengeThunk.fulfilled, (state, action) => {
|
||||||
|
state.createStatus = 'succeeded';
|
||||||
|
state.createError = undefined;
|
||||||
|
const challenge = action.payload;
|
||||||
|
const existing = state.entities[challenge.id];
|
||||||
|
const source = ChallengeSource.CUSTOM;
|
||||||
|
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
|
||||||
|
state.orderedIds = [challenge.id, ...state.orderedIds.filter((id) => id !== challenge.id)];
|
||||||
|
})
|
||||||
|
.addCase(createCustomChallengeThunk.rejected, (state, action) => {
|
||||||
|
state.createStatus = 'failed';
|
||||||
|
state.createError = action.payload ?? toErrorMessage(action.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder
|
||||||
|
.addCase(joinChallengeByCode.pending, (state) => {
|
||||||
|
state.joinByCodeStatus = 'loading';
|
||||||
|
state.joinByCodeError = undefined;
|
||||||
|
})
|
||||||
|
.addCase(joinChallengeByCode.fulfilled, (state, action) => {
|
||||||
|
state.joinByCodeStatus = 'succeeded';
|
||||||
|
state.joinByCodeError = undefined;
|
||||||
|
const { challenge, progress } = action.payload;
|
||||||
|
const existing = state.entities[challenge.id];
|
||||||
|
const source = challenge.source ?? existing?.source ?? ChallengeSource.SYSTEM;
|
||||||
|
const merged = { ...(existing ?? {}), ...challenge, progress, isJoined: true, source };
|
||||||
|
state.entities[challenge.id] = merged as ChallengeEntity;
|
||||||
|
state.orderedIds = [challenge.id, ...state.orderedIds.filter((id) => id !== challenge.id)];
|
||||||
|
})
|
||||||
|
.addCase(joinChallengeByCode.rejected, (state, action) => {
|
||||||
|
state.joinByCodeStatus = 'failed';
|
||||||
|
state.joinByCodeError = action.payload ?? toErrorMessage(action.error);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const { resetJoinByCodeState } = challengesSlice.actions;
|
||||||
|
|
||||||
export default challengesSlice.reducer;
|
export default challengesSlice.reducer;
|
||||||
|
|
||||||
const selectChallengesState = (state: RootState) => state.challenges;
|
const selectChallengesState = (state: RootState) => state.challenges;
|
||||||
@@ -355,20 +437,30 @@ export const selectChallengeEntities = createSelector(
|
|||||||
(state) => state.entities
|
(state) => state.entities
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectChallengeOrder = createSelector(
|
const selectChallengeOrder = createSelector(
|
||||||
[selectChallengesState],
|
[selectChallengesState],
|
||||||
(state) => state.order
|
(state) => state.orderedIds
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectChallengeList = createSelector(
|
export const selectChallengeList = createSelector(
|
||||||
[selectChallengeEntities, selectChallengeOrder],
|
[selectChallengeEntities, selectChallengeOrder],
|
||||||
(entities, order) => order.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
|
(entities, orderedIds) => orderedIds.map((id) => entities[id]).filter(Boolean) as ChallengeEntity[]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectCustomChallengeList = createSelector(
|
||||||
|
[selectChallengeList],
|
||||||
|
(list) => list.filter((challenge) => challenge.source === ChallengeSource.CUSTOM)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectOfficialChallengeList = createSelector(
|
||||||
|
[selectChallengeList],
|
||||||
|
(list) => list.filter((challenge) => challenge.source !== ChallengeSource.CUSTOM)
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatNumberWithSeparator = (value: number): string =>
|
const formatNumberWithSeparator = (value: number): string =>
|
||||||
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
|
||||||
const formatMonthDay = (input: string | undefined): string | undefined => {
|
const formatMonthDay = (input: string | number | undefined): string | undefined => {
|
||||||
if (!input) return undefined;
|
if (!input) return undefined;
|
||||||
const date = new Date(input);
|
const date = new Date(input);
|
||||||
if (Number.isNaN(date.getTime())) return undefined;
|
if (Number.isNaN(date.getTime())) return undefined;
|
||||||
@@ -384,6 +476,26 @@ const buildDateRangeLabel = (challenge: ChallengeEntity): string => {
|
|||||||
return challenge.periodLabel ?? challenge.durationLabel;
|
return challenge.periodLabel ?? challenge.durationLabel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deriveStatus = (challenge: ChallengeEntity): ChallengeStatus => {
|
||||||
|
if (challenge.status) return challenge.status;
|
||||||
|
if (challenge.challengeState === ChallengeState.ARCHIVED) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
const now = dayjs();
|
||||||
|
const start = challenge.startAt ? dayjs(challenge.startAt) : null;
|
||||||
|
const end = challenge.endAt ? dayjs(challenge.endAt) : null;
|
||||||
|
if (start?.isValid() && start.isAfter(now)) {
|
||||||
|
return 'upcoming';
|
||||||
|
}
|
||||||
|
if (end?.isValid() && end.isBefore(now)) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
return 'ongoing';
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_CHALLENGE_IMAGE =
|
||||||
|
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1000&q=80';
|
||||||
|
|
||||||
export type ChallengeCardViewModel = {
|
export type ChallengeCardViewModel = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -392,7 +504,7 @@ export type ChallengeCardViewModel = {
|
|||||||
participantsLabel: string;
|
participantsLabel: string;
|
||||||
status: ChallengeStatus;
|
status: ChallengeStatus;
|
||||||
isJoined: boolean;
|
isJoined: boolean;
|
||||||
endAt?: string;
|
endAt?: string | number;
|
||||||
periodLabel?: string;
|
periodLabel?: string;
|
||||||
durationLabel: string;
|
durationLabel: string;
|
||||||
requirementLabel: string;
|
requirementLabel: string;
|
||||||
@@ -401,27 +513,109 @@ export type ChallengeCardViewModel = {
|
|||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
progress?: ChallengeProgress;
|
progress?: ChallengeProgress;
|
||||||
avatars: string[];
|
avatars: string[];
|
||||||
|
source?: ChallengeSource;
|
||||||
|
shareCode?: string | null;
|
||||||
|
challengeState?: ChallengeState;
|
||||||
|
progressUnit?: string;
|
||||||
|
targetValue?: number;
|
||||||
|
isCreator?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
||||||
challenges.map<ChallengeCardViewModel>((challenge) => ({
|
challenges.map<ChallengeCardViewModel>((challenge) => {
|
||||||
id: challenge.id,
|
const participants =
|
||||||
title: challenge.title,
|
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
||||||
image: challenge.image,
|
return {
|
||||||
dateRange: buildDateRangeLabel(challenge),
|
id: challenge.id,
|
||||||
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
|
title: challenge.title,
|
||||||
status: challenge.status,
|
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
||||||
isJoined: challenge.isJoined,
|
dateRange: buildDateRangeLabel(challenge),
|
||||||
endAt: challenge.endAt,
|
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
||||||
periodLabel: challenge.periodLabel,
|
status: deriveStatus(challenge),
|
||||||
durationLabel: challenge.durationLabel,
|
isJoined: challenge.isJoined,
|
||||||
requirementLabel: challenge.requirementLabel,
|
endAt: challenge.endAt,
|
||||||
highlightTitle: challenge.highlightTitle,
|
periodLabel: challenge.periodLabel,
|
||||||
highlightSubtitle: challenge.highlightSubtitle,
|
durationLabel: challenge.durationLabel,
|
||||||
ctaLabel: challenge.ctaLabel,
|
requirementLabel: challenge.requirementLabel,
|
||||||
progress: challenge.progress,
|
highlightTitle: challenge.highlightTitle,
|
||||||
avatars: [],
|
highlightSubtitle: challenge.highlightSubtitle,
|
||||||
}))
|
ctaLabel: challenge.ctaLabel,
|
||||||
|
progress: challenge.progress,
|
||||||
|
avatars: [],
|
||||||
|
source: challenge.source,
|
||||||
|
shareCode: challenge.shareCode ?? null,
|
||||||
|
challengeState: challenge.challengeState,
|
||||||
|
progressUnit: challenge.progressUnit,
|
||||||
|
targetValue: challenge.targetValue,
|
||||||
|
isCreator: challenge.isCreator,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectCustomChallengeCards = createSelector(
|
||||||
|
[selectCustomChallengeList],
|
||||||
|
(challenges) =>
|
||||||
|
challenges.map<ChallengeCardViewModel>((challenge) => {
|
||||||
|
const participants =
|
||||||
|
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
||||||
|
return {
|
||||||
|
id: challenge.id,
|
||||||
|
title: challenge.title,
|
||||||
|
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
||||||
|
dateRange: buildDateRangeLabel(challenge),
|
||||||
|
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
||||||
|
status: deriveStatus(challenge),
|
||||||
|
isJoined: challenge.isJoined,
|
||||||
|
endAt: challenge.endAt,
|
||||||
|
periodLabel: challenge.periodLabel,
|
||||||
|
durationLabel: challenge.durationLabel,
|
||||||
|
requirementLabel: challenge.requirementLabel,
|
||||||
|
highlightTitle: challenge.highlightTitle,
|
||||||
|
highlightSubtitle: challenge.highlightSubtitle,
|
||||||
|
ctaLabel: challenge.ctaLabel,
|
||||||
|
progress: challenge.progress,
|
||||||
|
avatars: [],
|
||||||
|
source: challenge.source ?? ChallengeSource.CUSTOM,
|
||||||
|
shareCode: challenge.shareCode ?? null,
|
||||||
|
challengeState: challenge.challengeState,
|
||||||
|
progressUnit: challenge.progressUnit,
|
||||||
|
targetValue: challenge.targetValue,
|
||||||
|
isCreator: challenge.isCreator,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectOfficialChallengeCards = createSelector(
|
||||||
|
[selectOfficialChallengeList],
|
||||||
|
(challenges) =>
|
||||||
|
challenges.map<ChallengeCardViewModel>((challenge) => {
|
||||||
|
const participants =
|
||||||
|
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
||||||
|
return {
|
||||||
|
id: challenge.id,
|
||||||
|
title: challenge.title,
|
||||||
|
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
||||||
|
dateRange: buildDateRangeLabel(challenge),
|
||||||
|
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
||||||
|
status: deriveStatus(challenge),
|
||||||
|
isJoined: challenge.isJoined,
|
||||||
|
endAt: challenge.endAt,
|
||||||
|
periodLabel: challenge.periodLabel,
|
||||||
|
durationLabel: challenge.durationLabel,
|
||||||
|
requirementLabel: challenge.requirementLabel,
|
||||||
|
highlightTitle: challenge.highlightTitle,
|
||||||
|
highlightSubtitle: challenge.highlightSubtitle,
|
||||||
|
ctaLabel: challenge.ctaLabel,
|
||||||
|
progress: challenge.progress,
|
||||||
|
avatars: [],
|
||||||
|
source: challenge.source ?? ChallengeSource.SYSTEM,
|
||||||
|
shareCode: challenge.shareCode ?? null,
|
||||||
|
challengeState: challenge.challengeState,
|
||||||
|
progressUnit: challenge.progressUnit,
|
||||||
|
targetValue: challenge.targetValue,
|
||||||
|
isCreator: challenge.isCreator,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectChallengeById = (id: string) =>
|
export const selectChallengeById = (id: string) =>
|
||||||
@@ -462,3 +656,23 @@ export const selectChallengeRankingLoadMoreStatus = (id: string) =>
|
|||||||
|
|
||||||
export const selectChallengeRankingError = (id: string) =>
|
export const selectChallengeRankingError = (id: string) =>
|
||||||
createSelector([selectChallengesState], (state) => state.rankingError[id]);
|
createSelector([selectChallengesState], (state) => state.rankingError[id]);
|
||||||
|
|
||||||
|
export const selectCreateChallengeStatus = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.createStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectCreateChallengeError = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.createError
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectJoinByCodeStatus = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.joinByCodeStatus
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectJoinByCodeError = createSelector(
|
||||||
|
[selectChallengesState],
|
||||||
|
(state) => state.joinByCodeError
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,65 +5,51 @@ import AsyncStorage from '@/utils/kvStore';
|
|||||||
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
// 预加载的用户数据存储
|
/**
|
||||||
let preloadedUserData: {
|
* 同步加载用户数据(在 Redux store 初始化时立即执行)
|
||||||
token: string | null;
|
* 使用 getItemSync 确保数据在 store 创建前就已加载
|
||||||
profile: UserProfile;
|
*/
|
||||||
privacyAgreed: boolean;
|
function loadUserDataSync() {
|
||||||
onboardingCompleted: boolean;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
// 预加载用户数据的函数
|
|
||||||
export async function preloadUserData() {
|
|
||||||
try {
|
try {
|
||||||
const [profileStr, privacyAgreedStr, token, onboardingCompletedStr] = await Promise.all([
|
const profileStr = AsyncStorage.getItemSync(STORAGE_KEYS.userProfile);
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.userProfile),
|
const token = AsyncStorage.getItemSync(STORAGE_KEYS.authToken);
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.privacyAgreed),
|
const onboardingCompletedStr = AsyncStorage.getItemSync(STORAGE_KEYS.onboardingCompleted);
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.authToken),
|
|
||||||
AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let profile: UserProfile = {
|
let profile: UserProfile = {
|
||||||
memberNumber: 0
|
memberNumber: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (profileStr) {
|
if (profileStr) {
|
||||||
try {
|
try {
|
||||||
profile = JSON.parse(profileStr) as UserProfile;
|
profile = JSON.parse(profileStr) as UserProfile;
|
||||||
} catch {
|
} catch {
|
||||||
profile = {
|
profile = { memberNumber: 0 };
|
||||||
memberNumber: 0
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const privacyAgreed = privacyAgreedStr === 'true';
|
|
||||||
const onboardingCompleted = onboardingCompletedStr === 'true';
|
const onboardingCompleted = onboardingCompletedStr === 'true';
|
||||||
|
|
||||||
// 如果有 token,需要设置到 API 客户端
|
// 如果有 token,需要异步设置到 API 客户端(但不阻塞初始化)
|
||||||
if (token) {
|
if (token) {
|
||||||
await setAuthToken(token);
|
setAuthToken(token).catch(err => {
|
||||||
|
console.error('设置 auth token 失败:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadedUserData = { token, profile, privacyAgreed, onboardingCompleted };
|
return { token, profile, onboardingCompleted };
|
||||||
return preloadedUserData;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('预加载用户数据失败:', error);
|
console.error('同步加载用户数据失败:', error);
|
||||||
preloadedUserData = {
|
return {
|
||||||
token: null,
|
token: null,
|
||||||
profile: {
|
profile: { memberNumber: 0 },
|
||||||
memberNumber: 0
|
|
||||||
},
|
|
||||||
privacyAgreed: false,
|
|
||||||
onboardingCompleted: false
|
onboardingCompleted: false
|
||||||
};
|
};
|
||||||
return preloadedUserData;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取预加载的用户数据
|
// 在模块加载时立即同步加载用户数据
|
||||||
function getPreloadedUserData() {
|
const preloadedUserData = loadUserDataSync();
|
||||||
return preloadedUserData || { token: null, profile: {}, privacyAgreed: false, onboardingCompleted: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Gender = 'male' | 'female' | '';
|
export type Gender = 'male' | 'female' | '';
|
||||||
|
|
||||||
@@ -120,22 +106,23 @@ export type UserState = {
|
|||||||
export const DEFAULT_MEMBER_NAME = '朋友';
|
export const DEFAULT_MEMBER_NAME = '朋友';
|
||||||
|
|
||||||
const getInitialState = (): UserState => {
|
const getInitialState = (): UserState => {
|
||||||
const preloaded = getPreloadedUserData();
|
// 使用模块加载时同步加载的数据
|
||||||
|
console.log('初始化 Redux state,使用预加载数据:', preloadedUserData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: preloaded.token,
|
token: preloadedUserData.token,
|
||||||
profile: {
|
profile: {
|
||||||
name: DEFAULT_MEMBER_NAME,
|
name: DEFAULT_MEMBER_NAME,
|
||||||
isVip: false,
|
isVip: false,
|
||||||
freeUsageCount: 3,
|
freeUsageCount: 3,
|
||||||
memberNumber: 0,
|
|
||||||
maxUsageCount: 5,
|
maxUsageCount: 5,
|
||||||
...preloaded.profile, // 合并预加载的用户资料
|
...preloadedUserData.profile, // 合并预加载的用户资料(包含 memberNumber)
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
weightHistory: [],
|
weightHistory: [],
|
||||||
activityHistory: [],
|
activityHistory: [],
|
||||||
onboardingCompleted: preloaded.onboardingCompleted, // 引导完成状态
|
onboardingCompleted: preloadedUserData.onboardingCompleted, // 引导完成状态
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,8 +185,11 @@ export const login = createAsyncThunk(
|
|||||||
|
|
||||||
if (!token) throw new Error('登录响应缺少 token');
|
if (!token) throw new Error('登录响应缺少 token');
|
||||||
|
|
||||||
|
// 先持久化到本地存储
|
||||||
await AsyncStorage.setItem(STORAGE_KEYS.authToken, token);
|
await AsyncStorage.setItem(STORAGE_KEYS.authToken, token);
|
||||||
await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {}));
|
await AsyncStorage.setItem(STORAGE_KEYS.userProfile, JSON.stringify(profile ?? {}));
|
||||||
|
|
||||||
|
// 再设置到 API 客户端(内部会同步更新 AsyncStorage)
|
||||||
await setAuthToken(token);
|
await setAuthToken(token);
|
||||||
|
|
||||||
return { token, profile } as { token: string; profile: UserProfile };
|
return { token, profile } as { token: string; profile: UserProfile };
|
||||||
@@ -222,12 +212,15 @@ export const setOnboardingCompleted = createAsyncThunk('user/setOnboardingComple
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const logout = createAsyncThunk('user/logout', async () => {
|
export const logout = createAsyncThunk('user/logout', async () => {
|
||||||
|
// 先清除 API 客户端的 token(内部会清除 AsyncStorage)
|
||||||
|
await setAuthToken(null);
|
||||||
|
|
||||||
|
// 再清除其他本地存储数据
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
AsyncStorage.removeItem(STORAGE_KEYS.authToken),
|
|
||||||
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
|
AsyncStorage.removeItem(STORAGE_KEYS.userProfile),
|
||||||
AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed),
|
AsyncStorage.removeItem(STORAGE_KEYS.privacyAgreed),
|
||||||
]);
|
]);
|
||||||
await setAuthToken(null);
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user