feat(challenges): 添加自定义挑战功能和多语言支持

- 新增自定义挑战创建页面,支持设置挑战类型、时间范围、目标值等
- 实现挑战邀请码系统,支持通过邀请码加入自定义挑战
- 完善挑战详情页面的多语言翻译支持
- 优化用户认证状态检查逻辑,使用token作为主要判断依据
- 添加阿里字体文件支持,提升UI显示效果
- 改进确认弹窗组件,支持Liquid Glass效果和自定义内容
- 优化应用启动流程,直接读取onboarding状态而非预加载用户数据
This commit is contained in:
richarjiang
2025-11-26 16:39:01 +08:00
parent 3ad0e08d58
commit 39671ed70f
24 changed files with 3124 additions and 727 deletions

View File

@@ -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,19 +166,23 @@
## 下一步计划
### 短期目标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 端管理界面

View File

@@ -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}
@@ -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', // 保证玻璃边界圆角效果
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}
@@ -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', // 保证玻璃边界圆角效果
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,10 +387,12 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
```
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
```typescript
{isLiquidGlassAvailable() ? (
{
isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
activeOpacity={0.7}
>
<GlassView
@@ -362,18 +404,20 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
<Ionicons name="time-outline" size={24} color="#333" />
</GlassView>
</TouchableOpacity>
) : (
) : (
<TouchableOpacity
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
style={[styles.historyButton, styles.fallbackBackground]}
activeOpacity={0.7}
>
<Ionicons name="time-outline" size={24} color="#333" />
</TouchableOpacity>
)}
);
}
```
### 重要注意事项
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
3. **登录重定向**:登录页面会接收 `redirectTo``redirectParams` 参数用于登录后跳转
@@ -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,14 +448,17 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
**最后更新**: 2025-10-16
### 问题描述
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
### 解决方案
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
### 实现模式
#### 1. 添加新路由常量
`constants/Routes.ts` 文件中添加新的路由常量:
```typescript
@@ -416,24 +466,26 @@ 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` - 食物拍照页面实现
## 多语言翻译实现规范
**最后更新**: 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. **测试验证**:在开发完成后测试语言切换功能是否正常

View File

@@ -99,7 +99,7 @@ export default function TabLayout() {
color: colorTokens.tabIconSelected,
fontSize: 12,
fontWeight: '600',
marginLeft: 6,
marginLeft: 6
}}
numberOfLines={1}
>

View File

@@ -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,42 +106,92 @@ export default function ChallengesScreen() {
? ['#1f2230', '#10131e']
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
useEffect(() => {
if (!joinModalVisible) {
dispatch(resetJoinByCodeState());
setShareCodeInput('');
}
}, [dispatch, joinModalVisible]);
const handleCreatePress = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
router.push('/challenges/create-custom');
}, [ensureLoggedIn, router]);
const handleOpenJoin = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok) return;
setJoinModalVisible(true);
}, [ensureLoggedIn]);
const isJoiningByCode = joinByCodeStatus === 'loading';
const handleSubmitShareCode = useCallback(async () => {
if (isJoiningByCode) return;
const ok = await ensureLoggedIn();
if (!ok) return;
if (!shareCodeInput.trim()) {
Toast.warning(t('challenges.invalidInviteCode'));
return;
}
const formatted = shareCodeInput.trim().toUpperCase();
try {
const result = await dispatch(joinChallengeByCode(formatted)).unwrap();
await dispatch(fetchChallenges());
setJoinModalVisible(false);
Toast.success(t('challenges.joinSuccess'));
router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } });
} catch (error) {
const message = typeof error === 'string' ? error : t('challenges.joinFailed');
Toast.error(message);
}
}, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]);
const renderChallenges = () => {
if (listStatus === 'loading' && challenges.length === 0) {
if (listStatus === 'loading' && allChallenges.length === 0) {
return (
<View style={styles.stateContainer}>
<ActivityIndicator color={colorTokens.primary} />
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
</View>
);
}
if (listStatus === 'failed' && challenges.length === 0) {
if (listStatus === 'failed' && allChallenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
{listError ?? '加载挑战失败,请稍后重试'}
{listError ?? t('challenges.loadFailed')}
</Text>
<TouchableOpacity
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
activeOpacity={0.9}
onPress={() => dispatch(fetchChallenges())}
>
<Text style={[styles.retryText, { color: colorTokens.primary }]}></Text>
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
</TouchableOpacity>
</View>
);
}
if (challenges.length === 0) {
if (customChallenges.length === 0 && officialChallenges.length === 0) {
return (
<View style={styles.stateContainer}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
</View>
);
}
return challenges.map((challenge) => (
return (
<View style={styles.cardGroups}>
{joinedCustomChallenges.length ? (
<>
<View style={styles.sectionHeaderRow}>
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
</View>
<View style={styles.cardsContainer}>
{joinedCustomChallenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
@@ -131,7 +202,36 @@ export default function ChallengesScreen() {
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
));
))}
</View>
</>
) : null}
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
</View>
{officialChallenges.length ? (
<View style={styles.cardsContainer}>
{officialChallenges.map((challenge) => (
<ChallengeCard
key={challenge.id}
challenge={challenge}
surfaceColor={colorTokens.surface}
textColor={colorTokens.text}
mutedColor={colorTokens.textSecondary}
onPress={() =>
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
}
/>
))}
</View>
) : (
<View style={[styles.stateContainer, styles.customEmpty]}>
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
</View>
)}
</View>
);
};
return (
@@ -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>
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
<LinearGradient
colors={[colorTokens.primary, colorTokens.accentPurple]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.giftButton}
<View style={styles.headerActions}>
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
{glassAvailable ? (
<GlassView
style={styles.joinButtonGlass}
glassEffectStyle="regular"
tintColor="rgba(255,255,255,0.18)"
isInteractive
>
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
</LinearGradient>
</TouchableOpacity> */}
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
</GlassView>
) : (
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
{glassAvailable ? (
<GlassView
style={styles.createButton}
tintColor="rgba(255,255,255,0.22)"
isInteractive
>
<Ionicons name="add" size={18} color="#0f1528" />
</GlassView>
) : (
<View style={[styles.createButton, styles.createButtonFallback]}>
<Ionicons name="add" size={18} color={colorTokens.text} />
</View>
)}
</TouchableOpacity>
</View>
</View>
{ongoingChallenges.length ? (
@@ -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',
},
});

View File

@@ -598,6 +598,7 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '700',
color: '#192126',
fontFamily: 'AliRegular'
},
debugButtonsContainer: {
flexDirection: 'row',

View File

@@ -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) {

View File

@@ -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' },
Alert.alert(
t('challengeDetail.alert.leaveConfirm.title'),
t('challengeDetail.alert.leaveConfirm.message'),
[
{ text: t('challengeDetail.alert.leaveConfirm.cancel'), style: 'cancel' },
{
text: '退出挑战',
text: t('challengeDetail.alert.leaveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleLeave();
},
},
]);
]
);
};
const handleProgressReport = () => {
const handleProgressReport = async () => {
if (!id || progressStatus === 'loading') {
return;
}
dispatch(reportChallengeProgress({ id }));
if (hasCheckedInToday) {
Toast.info(t('challengeDetail.checkIn.toast.alreadyChecked'));
return;
}
if (challenge?.status === 'upcoming') {
Toast.info(t('challengeDetail.checkIn.toast.notStarted'));
return;
}
if (challenge?.status === 'expired') {
Toast.info(t('challengeDetail.checkIn.toast.expired'));
return;
}
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return;
}
if (!isJoined) {
Toast.info(t('challengeDetail.checkIn.toast.mustJoin'));
return;
}
try {
await dispatch(reportChallengeProgress({ id, value: 1 })).unwrap();
Toast.success(t('challengeDetail.checkIn.toast.success'));
} catch (error) {
Toast.error(t('challengeDetail.checkIn.toast.failed'));
}
};
const handleCopyShareCode = async () => {
if (!challenge?.shareCode) return;
await Clipboard.setStringAsync(challenge.shareCode);
// 添加震动反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Toast.success(t('challengeDetail.shareCode.copied'));
};
const isJoined = challenge?.isJoined ?? false;
const isLoadingInitial = detailStatus === 'loading' && !challenge;
if (!id) {
return (
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
<View style={styles.missingContainer}>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.notFound')}</Text>
</View>
</SafeAreaView>
);
@@ -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}>
{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',
},
});

View 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',
},
});

View File

@@ -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

Binary file not shown.

Binary file not shown.

View File

@@ -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,

View File

@@ -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}
>
{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}
>
{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>
) : (
<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',

View File

@@ -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;

View File

@@ -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',
},
},
},
},
};

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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 = {

View File

@@ -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`
);
}

View File

@@ -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,16 +513,25 @@ 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) => ({
challenges.map<ChallengeCardViewModel>((challenge) => {
const participants =
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
return {
id: challenge.id,
title: challenge.title,
image: challenge.image,
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
dateRange: buildDateRangeLabel(challenge),
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
status: challenge.status,
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
status: deriveStatus(challenge),
isJoined: challenge.isJoined,
endAt: challenge.endAt,
periodLabel: challenge.periodLabel,
@@ -421,7 +542,80 @@ export const selectChallengeCards = createSelector([selectChallengeList], (chall
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
);

View File

@@ -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;
});