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