Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39671ed70f | ||
|
|
3ad0e08d58 | ||
|
|
6f2b7eb45e | ||
|
|
3db2d39a58 | ||
|
|
c1c9f22111 | ||
| 8cbf6be50a | |||
|
|
bcb910140e | ||
|
|
29942feee9 | ||
|
|
84abfa2506 |
@@ -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 端管理界面
|
||||
|
||||
@@ -5,26 +5,31 @@
|
||||
**最后更新**: 2025-10-24
|
||||
|
||||
### 重要规则
|
||||
|
||||
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
|
||||
|
||||
### 问题描述
|
||||
|
||||
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons,以保持图标库的一致性。
|
||||
|
||||
### 解决方案
|
||||
|
||||
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 替换导入语句
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
|
||||
// ✅ 正确写法
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
```
|
||||
|
||||
#### 2. 替换图标名称和属性
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
|
||||
@@ -34,6 +39,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
```
|
||||
|
||||
#### 3. 常见图标映射
|
||||
|
||||
- `arrow-back-ios` → `chevron-back` (返回按钮)
|
||||
- `auto-awesome` → `star` (星星/自动推荐)
|
||||
- `tips-and-updates` → `bulb` (提示/建议)
|
||||
@@ -42,6 +48,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
- `remove` → `remove` (移除/删除,名称相同)
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **图标大小调整**:Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
|
||||
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
|
||||
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
|
||||
@@ -49,6 +56,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
|
||||
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
|
||||
|
||||
@@ -57,21 +65,25 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
**最后更新**: 2025-10-24
|
||||
|
||||
### 重要原则
|
||||
|
||||
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件
|
||||
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
@@ -81,9 +93,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.button}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="icon-name" size={20} color="#333" />
|
||||
</GlassView>
|
||||
@@ -96,26 +108,28 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20, // 圆形按钮
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
||||
borderRadius: 20, // 圆形按钮
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||
// 其他通用样式...
|
||||
},
|
||||
fallbackButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
@@ -124,6 +138,7 @@ const styles = StyleSheet.create({
|
||||
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
|
||||
@@ -132,6 +147,7 @@ const styles = StyleSheet.create({
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
@@ -141,24 +157,29 @@ const styles = StyleSheet.create({
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { useSafeAreaTop } from "@/hooks/useSafeAreaWithPadding";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取 safeAreaTop
|
||||
|
||||
```typescript
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
```
|
||||
|
||||
#### 3. 应用到内容容器
|
||||
|
||||
```typescript
|
||||
// 方式1: 直接应用到 View 组件
|
||||
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
||||
@@ -175,11 +196,13 @@ const safeAreaTop = useSafeAreaTop()
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
|
||||
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
|
||||
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
|
||||
|
||||
### 示例代码
|
||||
|
||||
```typescript
|
||||
// ❌ 错误写法 - 在 StyleSheet 中使用变量
|
||||
const styles = StyleSheet.create({
|
||||
@@ -193,6 +216,7 @@ const styles = StyleSheet.create({
|
||||
```
|
||||
|
||||
### 参考页面
|
||||
|
||||
- `app/steps/detail.tsx`
|
||||
- `app/water/detail.tsx`
|
||||
- `app/profile/goals.tsx`
|
||||
@@ -204,24 +228,29 @@ const styles = StyleSheet.create({
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件和函数
|
||||
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
@@ -231,9 +260,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
</GlassView>
|
||||
@@ -246,25 +275,27 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
glassButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18, // 圆形按钮
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
||||
borderRadius: 18, // 圆形按钮
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||
},
|
||||
fallbackButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(244, 67, 54, 0.3)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
borderColor: "rgba(244, 67, 54, 0.3)",
|
||||
backgroundColor: "rgba(244, 67, 54, 0.1)",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
@@ -272,6 +303,7 @@ const styles = StyleSheet.create({
|
||||
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
||||
@@ -279,6 +311,7 @@ const styles = StyleSheet.create({
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
@@ -288,27 +321,33 @@ const styles = StyleSheet.create({
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
|
||||
|
||||
### 权限校验原则
|
||||
|
||||
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useAuthGuard } from "@/hooks/useAuthGuard";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取方法
|
||||
|
||||
```typescript
|
||||
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 3. 替换导航操作
|
||||
|
||||
```typescript
|
||||
// ❌ 原来的写法 - 没有登录验证
|
||||
<TouchableOpacity
|
||||
@@ -324,6 +363,7 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 4. 服务端接口调用的登录验证
|
||||
|
||||
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
|
||||
|
||||
```typescript
|
||||
@@ -347,33 +387,37 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
|
||||
|
||||
```typescript
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.historyButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
{
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.historyButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||
style={[styles.historyButton, styles.fallbackBackground]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
style={[styles.historyButton, styles.fallbackBackground]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
|
||||
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
|
||||
3. **登录重定向**:登录页面会接收 `redirectTo` 和 `redirectParams` 参数用于登录后跳转
|
||||
@@ -382,16 +426,19 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
6. **异步处理**:`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
|
||||
|
||||
### 其他可用方法
|
||||
|
||||
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
|
||||
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
|
||||
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
|
||||
|
||||
### 使用场景选择
|
||||
|
||||
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
|
||||
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
|
||||
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
|
||||
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
|
||||
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
|
||||
@@ -401,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. **测试验证**:在开发完成后测试语言切换功能是否正常
|
||||
|
||||
@@ -12,7 +12,9 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { selectEnabledTabs } from '@/store/tabBarConfigSlice';
|
||||
|
||||
// Tab configuration
|
||||
type TabConfig = {
|
||||
@@ -35,6 +37,9 @@ export default function TabLayout() {
|
||||
const pathname = usePathname();
|
||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取已启用的标签配置(按自定义顺序)
|
||||
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
const routeMap: Record<string, string> = {
|
||||
@@ -94,7 +99,7 @@ export default function TabLayout() {
|
||||
color: colorTokens.tabIconSelected,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
marginLeft: 6
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -174,42 +179,45 @@ export default function TabLayout() {
|
||||
tabBarShowLabel: false,
|
||||
});
|
||||
|
||||
// 根据配置渲染标签页
|
||||
if (glassEffectAvailable) {
|
||||
return <NativeTabs>
|
||||
<NativeTabs.Trigger name="statistics">
|
||||
<Label>{t('statistics.tabs.health')}</Label>
|
||||
<Icon sf="chart.pie.fill" drawable="custom_android_drawable" />
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="medications">
|
||||
<Icon sf="pills.fill" drawable="custom_android_drawable" />
|
||||
<Label>{t('statistics.tabs.medications')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="fasting">
|
||||
<Icon sf="timer" drawable="custom_android_drawable" />
|
||||
<Label>{t('statistics.tabs.fasting')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="challenges">
|
||||
<Icon sf="trophy.fill" drawable="custom_android_drawable" />
|
||||
<Label>{t('statistics.tabs.challenges')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
<NativeTabs.Trigger name="personal">
|
||||
<Icon sf="person.fill" drawable="custom_settings_drawable" />
|
||||
<Label>{t('statistics.tabs.personal')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
return (
|
||||
<NativeTabs>
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
return (
|
||||
<NativeTabs.Trigger key={tab.id} name={tab.id}>
|
||||
<Icon sf={tabConfig.icon as any} drawable="custom_android_drawable" />
|
||||
<Label>{t(tabConfig.titleKey)}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
);
|
||||
})}
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
|
||||
// 确定初始路由(第一个启用的标签)
|
||||
const initialRouteName = enabledTabs.length > 0 ? enabledTabs[0].id : 'statistics';
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="statistics"
|
||||
initialRouteName={initialRouteName}
|
||||
screenOptions={({ route }) => getScreenOptions(route.name)}
|
||||
>
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
<Tabs.Screen name="statistics" options={{ title: t('statistics.tabs.health') }} />
|
||||
<Tabs.Screen name="medications" options={{ title: t('statistics.tabs.medications') }} />
|
||||
<Tabs.Screen name="fasting" options={{ title: t('statistics.tabs.fasting') }} />
|
||||
<Tabs.Screen name="challenges" options={{ title: t('statistics.tabs.challenges') }} />
|
||||
<Tabs.Screen name="personal" options={{ title: t('statistics.tabs.personal') }} />
|
||||
return (
|
||||
<Tabs.Screen
|
||||
key={tab.id}
|
||||
name={tab.id}
|
||||
options={{ title: t(tabConfig.titleKey) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import ChallengeProgressCard from '@/components/challenges/ChallengeProgressCard';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
fetchChallenges,
|
||||
joinChallengeByCode,
|
||||
resetJoinByCodeState,
|
||||
selectChallengeCards,
|
||||
selectChallengesListError,
|
||||
selectChallengesListStatus,
|
||||
selectCustomChallengeCards,
|
||||
selectJoinByCodeError,
|
||||
selectJoinByCodeStatus,
|
||||
selectOfficialChallengeCards,
|
||||
type ChallengeCardViewModel,
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
@@ -22,6 +34,7 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions
|
||||
@@ -31,11 +44,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
const AVATAR_SIZE = 36;
|
||||
const CARD_IMAGE_WIDTH = 132;
|
||||
const CARD_IMAGE_HEIGHT = 96;
|
||||
const STATUS_LABELS: Record<'upcoming' | 'ongoing' | 'expired', string> = {
|
||||
upcoming: '即将开始',
|
||||
ongoing: '进行中',
|
||||
expired: '已结束',
|
||||
};
|
||||
|
||||
const CAROUSEL_ITEM_SPACING = 16;
|
||||
const MIN_CAROUSEL_CARD_WIDTH = 280;
|
||||
@@ -44,16 +52,32 @@ const DOT_BASE_SIZE = 6;
|
||||
export default function ChallengesScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
const colorTokens = Colors[theme];
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenges = useAppSelector(selectChallengeCards);
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
const allChallenges = useAppSelector(selectChallengeCards);
|
||||
const customChallenges = useAppSelector(selectCustomChallengeCards);
|
||||
|
||||
|
||||
const officialChallenges = useAppSelector(selectOfficialChallengeCards);
|
||||
const joinedCustomChallenges = useMemo(
|
||||
() => customChallenges.filter((item) => item.isJoined),
|
||||
[customChallenges]
|
||||
);
|
||||
const listStatus = useAppSelector(selectChallengesListStatus);
|
||||
const listError = useAppSelector(selectChallengesListError);
|
||||
const joinByCodeStatus = useAppSelector(selectJoinByCodeStatus);
|
||||
const joinByCodeError = useAppSelector(selectJoinByCodeError);
|
||||
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
||||
const [shareCodeInput, setShareCodeInput] = useState('');
|
||||
const ongoingChallenges = useMemo(() => {
|
||||
const now = dayjs();
|
||||
return challenges.filter((challenge) => {
|
||||
return allChallenges.filter((challenge) => {
|
||||
if (challenge.status !== 'ongoing' || !challenge.isJoined || !challenge.progress) {
|
||||
return false;
|
||||
}
|
||||
@@ -67,7 +91,7 @@ export default function ChallengesScreen() {
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [challenges]);
|
||||
}, [allChallenges]);
|
||||
const progressTrackColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : '#eceffa';
|
||||
const progressInactiveColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.24)' : '#dfe4f6';
|
||||
|
||||
@@ -82,53 +106,132 @@ export default function ChallengesScreen() {
|
||||
? ['#1f2230', '#10131e']
|
||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||
|
||||
useEffect(() => {
|
||||
if (!joinModalVisible) {
|
||||
dispatch(resetJoinByCodeState());
|
||||
setShareCodeInput('');
|
||||
}
|
||||
}, [dispatch, joinModalVisible]);
|
||||
|
||||
const handleCreatePress = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/challenges/create-custom');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
const handleOpenJoin = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setJoinModalVisible(true);
|
||||
}, [ensureLoggedIn]);
|
||||
|
||||
const isJoiningByCode = joinByCodeStatus === 'loading';
|
||||
|
||||
const handleSubmitShareCode = useCallback(async () => {
|
||||
if (isJoiningByCode) return;
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
if (!shareCodeInput.trim()) {
|
||||
Toast.warning(t('challenges.invalidInviteCode'));
|
||||
return;
|
||||
}
|
||||
const formatted = shareCodeInput.trim().toUpperCase();
|
||||
try {
|
||||
const result = await dispatch(joinChallengeByCode(formatted)).unwrap();
|
||||
await dispatch(fetchChallenges());
|
||||
setJoinModalVisible(false);
|
||||
Toast.success(t('challenges.joinSuccess'));
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: result.challenge.id } });
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : t('challenges.joinFailed');
|
||||
Toast.error(message);
|
||||
}
|
||||
}, [dispatch, ensureLoggedIn, isJoiningByCode, router, shareCodeInput]);
|
||||
|
||||
const renderChallenges = () => {
|
||||
if (listStatus === 'loading' && challenges.length === 0) {
|
||||
if (listStatus === 'loading' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>加载挑战中…</Text>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.loading')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (listStatus === 'failed' && challenges.length === 0) {
|
||||
if (listStatus === 'failed' && allChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>
|
||||
{listError ?? '加载挑战失败,请稍后重试'}
|
||||
{listError ?? t('challenges.loadFailed')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => dispatch(fetchChallenges())}
|
||||
>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challenges.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (challenges.length === 0) {
|
||||
if (customChallenges.length === 0 && officialChallenges.length === 0) {
|
||||
return (
|
||||
<View style={styles.stateContainer}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>暂无挑战,稍后再来探索。</Text>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.empty')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return challenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<View style={styles.cardGroups}>
|
||||
{joinedCustomChallenges.length ? (
|
||||
<>
|
||||
<View style={styles.sectionHeaderRow}>
|
||||
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.customChallenges')}</Text>
|
||||
</View>
|
||||
<View style={styles.cardsContainer}>
|
||||
{joinedCustomChallenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={[styles.sectionHeaderRow, { marginTop: joinedCustomChallenges.length ? 12 : 0 }]}>
|
||||
<Text style={[styles.sectionHeaderText, { color: colorTokens.text }]}>{t('challenges.officialChallengesTitle')}</Text>
|
||||
</View>
|
||||
{officialChallenges.length ? (
|
||||
<View style={styles.cardsContainer}>
|
||||
{officialChallenges.map((challenge) => (
|
||||
<ChallengeCard
|
||||
key={challenge.id}
|
||||
challenge={challenge}
|
||||
surfaceColor={colorTokens.surface}
|
||||
textColor={colorTokens.text}
|
||||
mutedColor={colorTokens.textSecondary}
|
||||
onPress={() =>
|
||||
router.push({ pathname: '/challenges/[id]', params: { id: challenge.id } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.stateContainer, styles.customEmpty]}>
|
||||
<Text style={[styles.stateText, { color: colorTokens.textSecondary }]}>{t('challenges.officialChallenges')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -143,19 +246,42 @@ export default function ChallengesScreen() {
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>挑战</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>参与精选活动,保持每日动力</Text>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>{t('challenges.title')}</Text>
|
||||
<Text style={[styles.subtitle, { color: colorTokens.textSecondary }]}>{t('challenges.subtitle')}</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.joinButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255,255,255,0.18)"
|
||||
isInteractive
|
||||
>
|
||||
<Text style={styles.joinButtonLabel}>{t('challenges.join')}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>{t('challenges.join')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={handleCreatePress} style={{ marginLeft: 10 }}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.createButton}
|
||||
tintColor="rgba(255,255,255,0.22)"
|
||||
isInteractive
|
||||
>
|
||||
<Ionicons name="add" size={18} color="#0f1528" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.createButton, styles.createButtonFallback]}>
|
||||
<Ionicons name="add" size={18} color={colorTokens.text} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* <TouchableOpacity activeOpacity={0.9} style={styles.giftShadow}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.primary, colorTokens.accentPurple]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.giftButton}
|
||||
>
|
||||
<IconSymbol name="gift.fill" size={18} color={colorTokens.onPrimary} />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity> */}
|
||||
</View>
|
||||
|
||||
{ongoingChallenges.length ? (
|
||||
@@ -172,6 +298,34 @@ export default function ChallengesScreen() {
|
||||
|
||||
<View style={styles.cardsContainer}>{renderChallenges()}</View>
|
||||
</ScrollView>
|
||||
<ConfirmationSheet
|
||||
visible={joinModalVisible}
|
||||
onClose={() => setJoinModalVisible(false)}
|
||||
onConfirm={handleSubmitShareCode}
|
||||
title={t('challenges.joinModal.title')}
|
||||
description={t('challenges.joinModal.description')}
|
||||
confirmText={isJoiningByCode ? t('challenges.joinModal.joining') : t('challenges.joinModal.confirm')}
|
||||
cancelText={t('challenges.joinModal.cancel')}
|
||||
loading={isJoiningByCode}
|
||||
content={
|
||||
<View style={styles.modalInputWrapper}>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
placeholder={t('challenges.joinModal.placeholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={shareCodeInput}
|
||||
onChangeText={(text) => setShareCodeInput(text.toUpperCase())}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
keyboardType="default"
|
||||
maxLength={12}
|
||||
/>
|
||||
{joinByCodeError && joinModalVisible ? (
|
||||
<Text style={styles.modalError}>{joinByCodeError}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +339,8 @@ type ChallengeCardProps = {
|
||||
};
|
||||
|
||||
function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress }: ChallengeCardProps) {
|
||||
const statusLabel = STATUS_LABELS[challenge.status] ?? challenge.status;
|
||||
const { t } = useI18n();
|
||||
const statusLabel = t(`challenges.statusLabels.${challenge.status}`) ?? challenge.status;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -235,7 +390,7 @@ function ChallengeCard({ challenge, surfaceColor, textColor, mutedColor, onPress
|
||||
style={[styles.cardParticipants, { color: mutedColor }]}
|
||||
>
|
||||
{challenge.participantsLabel}
|
||||
{challenge.isJoined ? ' · 已加入' : ''}
|
||||
{challenge.isJoined ? ` · ${t('challenges.joined')}` : ''}
|
||||
</Text>
|
||||
{challenge.avatars.length ? (
|
||||
<AvatarStack avatars={challenge.avatars} borderColor={surfaceColor} />
|
||||
@@ -325,7 +480,7 @@ function OngoingChallengesCarousel({
|
||||
>
|
||||
<ChallengeProgressCard
|
||||
title={item.title}
|
||||
endAt={item.endAt}
|
||||
endAt={item.endAt as string}
|
||||
progress={item.progress}
|
||||
style={styles.carouselProgressCard}
|
||||
backgroundColors={[colorTokens.card, colorTokens.card]}
|
||||
@@ -450,31 +605,79 @@ const styles = StyleSheet.create({
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
giftShadow: {
|
||||
shadowColor: 'rgba(94, 62, 199, 0.45)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 12,
|
||||
elevation: 8,
|
||||
borderRadius: 26,
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
giftButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 26,
|
||||
joinButtonGlass: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
createButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
},
|
||||
createButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||
},
|
||||
cardsContainer: {
|
||||
gap: 18,
|
||||
},
|
||||
cardGroups: {
|
||||
gap: 20,
|
||||
},
|
||||
sectionHeaderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionHeaderText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
customEmpty: {
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
primaryGhostButton: {
|
||||
marginTop: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: 14,
|
||||
},
|
||||
carouselContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
@@ -555,16 +758,19 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
cardDate: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
cardParticipants: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
cardExpired: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
@@ -594,6 +800,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#f7f9ff',
|
||||
letterSpacing: 0.3,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
cardProgress: {
|
||||
marginTop: 8,
|
||||
@@ -614,4 +821,25 @@ const styles = StyleSheet.create({
|
||||
avatarOffset: {
|
||||
marginLeft: -12,
|
||||
},
|
||||
modalInputWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
gap: 6,
|
||||
},
|
||||
modalInput: {
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1.5,
|
||||
color: '#0f1528',
|
||||
},
|
||||
modalError: {
|
||||
marginTop: 10,
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import CelebrationAnimation, { CelebrationAnimationRef } from '@/components/CelebrationAnimation';
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { MedicationCard } from '@/components/medication/MedicationCard';
|
||||
import { TakenMedicationsStack } from '@/components/medication/TakenMedicationsStack';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { MedicalDisclaimerSheet } from '@/components/ui/MedicalDisclaimerSheet';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
|
||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||
import { getItemSync, setItemSync } from '@/utils/kvStore';
|
||||
@@ -45,6 +48,9 @@ export default function MedicationsScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colors: ThemeColors = Colors[theme];
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
const { ensureLoggedIn, isLoggedIn } = useAuthGuard();
|
||||
const { checkServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
|
||||
const [selectedDateIndex, setSelectedDateIndex] = useState<number>(selectedDate.date() - 1);
|
||||
const [activeFilter, setActiveFilter] = useState<MedicationFilter>('all');
|
||||
@@ -52,20 +58,43 @@ export default function MedicationsScreen() {
|
||||
const celebrationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isCelebrationVisible, setIsCelebrationVisible] = useState(false);
|
||||
const [disclaimerVisible, setDisclaimerVisible] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<'manual' | null>(null);
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||||
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
|
||||
|
||||
const handleOpenAddMedication = useCallback(() => {
|
||||
// 检查是否已经读过免责声明
|
||||
// 使用 useMemo 缓存 selector 实例,避免每次渲染都创建新的 selector
|
||||
const medicationSelector = useMemo(
|
||||
() => selectMedicationDisplayItemsByDate(selectedKey),
|
||||
[selectedKey]
|
||||
);
|
||||
const medicationsForDay = useAppSelector(medicationSelector);
|
||||
|
||||
// 直接跳转到 AI 相机页面
|
||||
const handleAddMedication = useCallback(async () => {
|
||||
// 先检查登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 检查 VIP 权限
|
||||
const access = checkServiceAccess();
|
||||
if (!access.canUseService) {
|
||||
openMembershipModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 直接跳转到 AI 相机页面
|
||||
router.push('/medications/ai-camera');
|
||||
}, [checkServiceAccess, ensureLoggedIn, openMembershipModal]);
|
||||
|
||||
const handleManualAdd = useCallback(() => {
|
||||
const hasRead = getItemSync(MEDICAL_DISCLAIMER_READ_KEY);
|
||||
setPendingAction('manual');
|
||||
|
||||
if (hasRead === 'true') {
|
||||
// 已读过,直接跳转
|
||||
setPendingAction(null);
|
||||
router.push('/medications/add-medication');
|
||||
} else {
|
||||
// 未读过,显示医疗免责声明弹窗
|
||||
setDisclaimerVisible(true);
|
||||
}
|
||||
}, []);
|
||||
@@ -74,12 +103,16 @@ export default function MedicationsScreen() {
|
||||
// 用户同意免责声明后,记录已读状态,关闭弹窗并跳转到添加页面
|
||||
setItemSync(MEDICAL_DISCLAIMER_READ_KEY, 'true');
|
||||
setDisclaimerVisible(false);
|
||||
router.push('/medications/add-medication');
|
||||
}, []);
|
||||
if (pendingAction === 'manual') {
|
||||
setPendingAction(null);
|
||||
router.push('/medications/add-medication');
|
||||
}
|
||||
}, [pendingAction]);
|
||||
|
||||
const handleDisclaimerClose = useCallback(() => {
|
||||
// 用户不接受免责声明,只关闭弹窗,不跳转,不记录已读状态
|
||||
setDisclaimerVisible(false);
|
||||
setPendingAction(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationManagement = useCallback(() => {
|
||||
@@ -111,9 +144,11 @@ export default function MedicationsScreen() {
|
||||
|
||||
// 加载药物和记录数据
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
dispatch(fetchMedications());
|
||||
dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||
}, [dispatch, selectedKey]);
|
||||
}, [dispatch, selectedKey, isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -126,17 +161,16 @@ export default function MedicationsScreen() {
|
||||
// 页面聚焦时刷新数据,确保从添加页面返回时能看到最新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 重新安排药品通知并刷新数据
|
||||
const refreshDataAndRescheduleNotifications = async () => {
|
||||
try {
|
||||
// 只获取一次药物数据,然后复用结果
|
||||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||||
|
||||
// 并行执行获取药物记录和安排通知
|
||||
const [recordsAction] = await Promise.all([
|
||||
dispatch(fetchMedicationRecords({ date: selectedKey })),
|
||||
medicationNotificationService.rescheduleAllMedicationNotifications(medications),
|
||||
]);
|
||||
// 获取药物记录
|
||||
const recordsAction = await dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||
|
||||
// 同步数据到小组件(仅同步今天的)
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
@@ -158,7 +192,7 @@ export default function MedicationsScreen() {
|
||||
};
|
||||
|
||||
refreshDataAndRescheduleNotifications();
|
||||
}, [dispatch, selectedKey])
|
||||
}, [dispatch, selectedKey, isLoggedIn])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -189,6 +223,16 @@ export default function MedicationsScreen() {
|
||||
return medicationsWithImages.filter((item: any) => item.status === activeFilter);
|
||||
}, [activeFilter, medicationsWithImages]);
|
||||
|
||||
const activeMedications = useMemo(() => {
|
||||
if (activeFilter !== 'all') return filteredMedications;
|
||||
return filteredMedications.filter((item: any) => item.status !== 'taken' && item.status !== 'skipped');
|
||||
}, [activeFilter, filteredMedications]);
|
||||
|
||||
const completedMedications = useMemo(() => {
|
||||
if (activeFilter !== 'all') return [];
|
||||
return filteredMedications.filter((item: any) => item.status === 'taken' || item.status === 'skipped');
|
||||
}, [activeFilter, filteredMedications]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const taken = medicationsWithImages.filter((item: any) => item.status === 'taken').length;
|
||||
// "未服用"计数包含 missed(已错过)和 upcoming(待服用)
|
||||
@@ -263,7 +307,7 @@ export default function MedicationsScreen() {
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAddMedication}
|
||||
onPress={handleAddMedication}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
@@ -354,7 +398,8 @@ export default function MedicationsScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.cardsWrapper}>
|
||||
{filteredMedications.map((item: any) => (
|
||||
{/* 渲染未服用的药物 */}
|
||||
{activeMedications.map((item: any) => (
|
||||
<MedicationCard
|
||||
key={item.id}
|
||||
medication={item}
|
||||
@@ -364,6 +409,17 @@ export default function MedicationsScreen() {
|
||||
onCelebrate={handleMedicationTakenCelebration}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 渲染已完成(服用/跳过)的药物堆叠 */}
|
||||
{completedMedications.length > 0 && (
|
||||
<TakenMedicationsStack
|
||||
medications={completedMedications}
|
||||
colors={colors}
|
||||
selectedDate={selectedDate}
|
||||
onOpenDetails={(item) => handleOpenMedicationDetails(item.medicationId)}
|
||||
onCelebrate={handleMedicationTakenCelebration}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
@@ -59,6 +60,7 @@ export default function PersonalScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const isLgAvaliable = isLiquidGlassAvailable();
|
||||
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
|
||||
@@ -78,7 +80,7 @@ export default function PersonalScreen() {
|
||||
]), [t]);
|
||||
|
||||
const activeLanguageCode = getNormalizedLanguage(i18n.language);
|
||||
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label ?? '';
|
||||
const activeLanguageLabel = languageOptions.find((option) => option.code === activeLanguageCode)?.label || '';
|
||||
|
||||
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
|
||||
setLanguageModalVisible(false);
|
||||
@@ -163,22 +165,25 @@ export default function PersonalScreen() {
|
||||
}
|
||||
}, [showcaseBadge]);
|
||||
|
||||
console.log('badgePreview', badgePreview);
|
||||
|
||||
|
||||
|
||||
// 首次加载时获取用户信息和数据
|
||||
useEffect(() => {
|
||||
dispatch(fetchAvailableBadges());
|
||||
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
dispatch(fetchMyProfile());
|
||||
dispatch(fetchActivityHistory());
|
||||
dispatch(fetchAvailableBadges());
|
||||
}, [dispatch]);
|
||||
}, [dispatch, isLoggedIn]);
|
||||
|
||||
// 页面聚焦时智能刷新(依赖 Redux 的缓存策略)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// 徽章数据由 Redux 的缓存策略控制,只有过期才会重新请求
|
||||
dispatch(fetchAvailableBadges());
|
||||
}, [dispatch])
|
||||
}, [dispatch, isLoggedIn])
|
||||
);
|
||||
|
||||
// 手动刷新处理
|
||||
@@ -299,11 +304,11 @@ export default function PersonalScreen() {
|
||||
<TouchableOpacity onPress={handleUserNamePress} activeOpacity={0.7}>
|
||||
<Text style={styles.userName}>{displayName}</Text>
|
||||
</TouchableOpacity>
|
||||
{userProfile.memberNumber && (
|
||||
{userProfile.memberNumber && String(userProfile.memberNumber).trim().length > 0 ? (
|
||||
<Text style={styles.userMemberNumber}>
|
||||
{t('personal.memberNumber', { number: userProfile.memberNumber })}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
{userProfile.freeUsageCount !== undefined && (
|
||||
<View style={styles.aiUsageContainer}>
|
||||
<Ionicons name="sparkles-outline" as any size={12} color="#9370DB" />
|
||||
@@ -364,8 +369,8 @@ export default function PersonalScreen() {
|
||||
}
|
||||
|
||||
const planName =
|
||||
activeMembershipPlanName?.trim() ||
|
||||
userProfile.vipPlanName?.trim() ||
|
||||
(activeMembershipPlanName && activeMembershipPlanName.trim()) ||
|
||||
(userProfile.vipPlanName && userProfile.vipPlanName.trim()) ||
|
||||
t('personal.membership.planFallback');
|
||||
|
||||
return (
|
||||
@@ -419,7 +424,7 @@ export default function PersonalScreen() {
|
||||
const StatsSection = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.cardContainer, {
|
||||
backgroundColor: 'unset'
|
||||
backgroundColor: 'transparent'
|
||||
}]}>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
@@ -439,48 +444,34 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
const BadgesPreviewSection = () => {
|
||||
const previewBadges = badgePreview.slice(0, 3);
|
||||
const hasBadges = previewBadges.length > 0;
|
||||
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
|
||||
// 优化性能:使用 useMemo 缓存计算结果,避免每次渲染都重新计算
|
||||
const BadgesPreviewSection = React.memo(() => {
|
||||
// 使用 useMemo 缓存切片和计算结果,只有当 badgePreview 或 badgeCounts 变化时才重新计算
|
||||
const { previewBadges, hasBadges, extraCount } = useMemo(() => {
|
||||
const previewBadges = badgePreview.slice(0, 3);
|
||||
const hasBadges = previewBadges.length > 0;
|
||||
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
|
||||
return { previewBadges, hasBadges, extraCount };
|
||||
}, [badgePreview, badgeCounts]);
|
||||
|
||||
// 使用 useMemo 缓存标题文本,避免每次渲染都调用 t() 函数
|
||||
const titleText = useMemo(() => t('personal.badgesPreview.title'), [t]);
|
||||
const emptyText = useMemo(() => t('personal.badgesPreview.empty'), [t]);
|
||||
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
<TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}>
|
||||
<Text style={styles.badgesRowTitle}>{t('personal.badgesPreview.title')}</Text>
|
||||
<Text style={styles.badgesRowTitle}>{titleText}</Text>
|
||||
{hasBadges ? (
|
||||
<View style={styles.badgesRowContent}>
|
||||
<View style={styles.badgesStack}>
|
||||
{previewBadges.map((badge, index) => (
|
||||
<View
|
||||
<BadgeCompactItem
|
||||
key={badge.code}
|
||||
style={[
|
||||
styles.badgeCompactBubble,
|
||||
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
|
||||
{
|
||||
marginLeft: index === 0 ? 0 : -12,
|
||||
zIndex: previewBadges.length - index,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{badge.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: badge.imageUrl }}
|
||||
style={styles.badgeCompactImage}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.badgeCompactFallback}>
|
||||
<Text style={styles.badgeCompactFallbackText}>{badge.icon ?? '🏅'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!badge.isAwarded && (
|
||||
<View style={styles.badgeCompactOverlay}>
|
||||
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
badge={badge}
|
||||
index={index}
|
||||
totalBadges={previewBadges.length}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{extraCount > 0 && (
|
||||
@@ -490,12 +481,60 @@ export default function PersonalScreen() {
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text>
|
||||
<Text style={styles.badgesRowEmpty}>{emptyText}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// 将徽章项提取为独立的 memo 组件,减少重复渲染
|
||||
const BadgeCompactItem = React.memo(({ badge, index, totalBadges }: {
|
||||
badge: BadgeDto;
|
||||
index: number;
|
||||
totalBadges: number;
|
||||
}) => {
|
||||
// 使用 useMemo 缓存样式计算,避免每次渲染都重新计算
|
||||
const badgeStyle = useMemo(() => [
|
||||
styles.badgeCompactBubble,
|
||||
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
|
||||
{
|
||||
marginLeft: index === 0 ? 0 : -12,
|
||||
zIndex: totalBadges - index,
|
||||
},
|
||||
], [badge.isAwarded, index, totalBadges]);
|
||||
|
||||
// 使用 useMemo 缓存图标文本,避免每次渲染都重新计算
|
||||
const iconText = useMemo(() =>
|
||||
(badge.icon && String(badge.icon).trim()) || '🏅',
|
||||
[badge.icon]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={badgeStyle}>
|
||||
{badge.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: badge.imageUrl }}
|
||||
style={styles.badgeCompactImage}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.badgeCompactFallback}>
|
||||
<Text style={styles.badgeCompactFallbackText}>
|
||||
{iconText}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!badge.isAwarded && (
|
||||
<View style={styles.badgeCompactOverlay}>
|
||||
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
// 菜单项组件
|
||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||
@@ -531,7 +570,7 @@ export default function PersonalScreen() {
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.menuRight}>
|
||||
{item.rightText ? (
|
||||
{item.rightText && String(item.rightText).trim() ? (
|
||||
<Text style={styles.menuRightText}>{item.rightText}</Text>
|
||||
) : null}
|
||||
<Ionicons name="chevron-forward" as any size={20} color="#CCCCCC" />
|
||||
@@ -582,7 +621,17 @@ export default function PersonalScreen() {
|
||||
icon: 'language-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.language.menuTitle'),
|
||||
onPress: () => setLanguageModalVisible(true),
|
||||
rightText: activeLanguageLabel,
|
||||
rightText: activeLanguageLabel || '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('personal.sections.customization'),
|
||||
items: [
|
||||
{
|
||||
icon: 'albums-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.menu.tabBarConfig'),
|
||||
onPress: () => router.push(ROUTES.TAB_BAR_CONFIG),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -671,8 +720,12 @@ export default function PersonalScreen() {
|
||||
disabled={isSwitchingLanguage}
|
||||
>
|
||||
<View style={styles.languageOptionTextGroup}>
|
||||
<Text style={styles.languageOptionLabel}>{option.label}</Text>
|
||||
<Text style={styles.languageOptionDescription}>{option.description}</Text>
|
||||
<Text style={styles.languageOptionLabel}>
|
||||
{(option.label && String(option.label).trim()) || ''}
|
||||
</Text>
|
||||
<Text style={styles.languageOptionDescription}>
|
||||
{(option.description && String(option.description).trim()) || ''}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<Ionicons name="checkmark-circle" as any size={20} color="#9370DB" />
|
||||
@@ -698,16 +751,12 @@ export default function PersonalScreen() {
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
@@ -759,33 +808,14 @@ export default function PersonalScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
height: '60%',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
|
||||
@@ -386,7 +386,7 @@ export default function ExploreScreen() {
|
||||
<View style={styles.headerContent}>
|
||||
{/* 左边logo */}
|
||||
<Image
|
||||
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
||||
source={require('@/assets/machine.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
@@ -598,6 +598,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
debugButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
107
app/_layout.tsx
@@ -10,6 +10,7 @@ import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||
import { hrvMonitorService } from '@/services/hrvMonitor';
|
||||
import { cleanupLegacyMedicationNotifications } from '@/services/medicationNotificationCleanup';
|
||||
import { clearBadgeCount, notificationService } from '@/services/notifications';
|
||||
import { setupQuickActions } from '@/services/quickActions';
|
||||
import { sleepMonitorService } from '@/services/sleepMonitor';
|
||||
@@ -23,6 +24,7 @@ import { createWaterRecordAction } from '@/store/waterSlice';
|
||||
import { loadActiveFastingSchedule } from '@/utils/fasting';
|
||||
import { initializeHealthPermissions } from '@/utils/health';
|
||||
import { MoodNotificationHelpers, NutritionNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getMoodReminderEnabled, getNutritionReminderEnabled, getWaterReminderSettings } from '@/utils/userPreferences';
|
||||
import { clearPendingWaterRecords, syncPendingWidgetChanges } from '@/utils/widgetDataSync';
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
@@ -34,6 +36,7 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { fetchChallenges } from '@/store/challengesSlice';
|
||||
import { loadTabBarConfigs } from '@/store/tabBarConfigSlice';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -119,15 +122,22 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
// 初始化底部栏配置
|
||||
useEffect(() => {
|
||||
dispatch(loadTabBarConfigs());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 基础服务初始化(不需要权限,总是执行)====================
|
||||
React.useEffect(() => {
|
||||
const initializeBasicServices = async () => {
|
||||
try {
|
||||
logger.info('🚀 开始初始化基础服务(不需要权限)...');
|
||||
|
||||
// 1. 加载用户数据(首屏展示需要)
|
||||
await dispatch(fetchMyProfile());
|
||||
logger.info('✅ 用户数据加载完成');
|
||||
if (isLoggedIn) {
|
||||
// 1. 加载用户数据(首屏展示需要)
|
||||
await dispatch(fetchMyProfile());
|
||||
logger.info('✅ 用户数据加载完成');
|
||||
}
|
||||
|
||||
// 2. 初始化 HealthKit 权限系统(不请求权限,仅初始化)
|
||||
initializeHealthPermissions();
|
||||
@@ -173,7 +183,6 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
permissionInitializedRef.current = true;
|
||||
|
||||
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
@@ -213,27 +222,57 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
logger.info('📢 开始批量注册通知提醒...');
|
||||
|
||||
// 并行注册所有通知,提高效率
|
||||
await Promise.all([
|
||||
// 营养提醒
|
||||
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 午餐提醒已注册')
|
||||
),
|
||||
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 晚餐提醒已注册')
|
||||
),
|
||||
|
||||
// 心情提醒
|
||||
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 心情提醒已注册')
|
||||
),
|
||||
|
||||
// 喝水提醒
|
||||
WaterNotificationHelpers.scheduleRegularWaterReminders(profile.name || '用户').then(() =>
|
||||
logger.info('✅ 喝水提醒已注册')
|
||||
),
|
||||
// 获取用户偏好设置
|
||||
const [nutritionReminderEnabled, moodReminderEnabled, waterSettings] = await Promise.all([
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
getWaterReminderSettings(),
|
||||
]);
|
||||
|
||||
// 准备所有通知注册任务
|
||||
const notificationTasks = [];
|
||||
|
||||
// 营养提醒 - 根据用户设置决定是否注册
|
||||
if (nutritionReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
NutritionNotificationHelpers.scheduleDailyLunchReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 午餐提醒已注册')
|
||||
),
|
||||
NutritionNotificationHelpers.scheduleDailyDinnerReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 晚餐提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启营养提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 心情提醒 - 根据用户设置决定是否注册
|
||||
if (moodReminderEnabled) {
|
||||
notificationTasks.push(
|
||||
MoodNotificationHelpers.scheduleDailyMoodReminder(profile.name || '').then(() =>
|
||||
logger.info('✅ 心情提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启心情提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 喝水提醒 - 根据用户设置决定是否注册
|
||||
if (waterSettings.enabled) {
|
||||
notificationTasks.push(
|
||||
WaterNotificationHelpers.scheduleCustomWaterReminders(profile.name || '用户', waterSettings).then(() =>
|
||||
logger.info('✅ 自定义喝水提醒已注册')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
logger.info('ℹ️ 用户未开启喝水提醒,跳过注册');
|
||||
}
|
||||
|
||||
// 并行执行所有通知注册任务
|
||||
if (notificationTasks.length > 0) {
|
||||
await Promise.all(notificationTasks);
|
||||
}
|
||||
|
||||
// 检查断食通知(如果有活跃计划)
|
||||
const fastingSchedule = store.getState().fasting.activeSchedule;
|
||||
if (fastingSchedule) {
|
||||
@@ -353,7 +392,12 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
await notificationService.initialize();
|
||||
logger.info('✅ 通知服务初始化完成');
|
||||
|
||||
// 2. 异步同步 Widget 数据(不阻塞主流程)
|
||||
// 2. 清理旧的药品本地通知(迁移到服务端推送)
|
||||
cleanupLegacyMedicationNotifications().catch(error => {
|
||||
logger.error('❌ 清理旧药品通知失败:', error);
|
||||
});
|
||||
|
||||
// 3. 异步同步 Widget 数据(不阻塞主流程)
|
||||
syncWidgetDataInBackground();
|
||||
|
||||
logger.info('🎉 权限相关服务初始化完成');
|
||||
@@ -401,8 +445,8 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// 2. 开发环境调试工具
|
||||
if (__DEV__ && BackgroundTaskDebugger) {
|
||||
BackgroundTaskDebugger.getInstance().initialize();
|
||||
logger.info('✅ 后台任务调试工具已初始化(开发环境)');
|
||||
logger.info('✅ 后台任务调试工具未初始化(开发环境)');
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('🎉 空闲服务初始化完成');
|
||||
@@ -466,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) {
|
||||
@@ -482,25 +528,22 @@ export default function RootLayout() {
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||
|
||||
<Stack.Screen name="ai-posture-assessment" />
|
||||
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="legal/user-agreement" options={{ headerShown: true, title: '用户协议' }} />
|
||||
<Stack.Screen name="legal/privacy-policy" options={{ headerShown: true, title: '隐私政策' }} />
|
||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="health-data-permissions"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<Stack.Screen name="medications/ai-camera" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="medications/ai-progress" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="badges/index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings/tab-bar-config" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { ChallengeSource } from '@/services/challengesApi';
|
||||
import {
|
||||
fetchChallengeDetail,
|
||||
fetchChallengeRankings,
|
||||
@@ -23,13 +24,17 @@ import {
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -87,6 +92,7 @@ export default function ChallengeDetailScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
|
||||
@@ -155,6 +161,24 @@ export default function ChallengeDetailScreen() {
|
||||
}, [showCelebration]);
|
||||
|
||||
const progress = challenge?.progress;
|
||||
const isJoined = challenge?.isJoined ?? false;
|
||||
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
|
||||
const lastProgressAt = useMemo(() => {
|
||||
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
|
||||
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
|
||||
}, [challenge?.progress]);
|
||||
const hasCheckedInToday = useMemo(() => {
|
||||
if (!challenge?.progress) {
|
||||
return false;
|
||||
}
|
||||
if (lastProgressAt) {
|
||||
const lastDate = dayjs(lastProgressAt);
|
||||
if (lastDate.isValid() && lastDate.isSame(dayjs(), 'day')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return challenge.progress.checkedInToday ?? false;
|
||||
}, [challenge?.progress, lastProgressAt]);
|
||||
|
||||
const rankingData = useMemo(() => {
|
||||
const source = rankingList?.items ?? challenge?.rankings ?? [];
|
||||
@@ -165,6 +189,7 @@ export default function ChallengeDetailScreen() {
|
||||
() => rankingData.filter((item) => item.avatar).map((item) => item.avatar as string).slice(0, 6),
|
||||
[rankingData],
|
||||
);
|
||||
const showShareCode = isJoined && Boolean(challenge?.shareCode);
|
||||
|
||||
const handleViewAllRanking = () => {
|
||||
if (!id) {
|
||||
@@ -192,7 +217,7 @@ export default function ChallengeDetailScreen() {
|
||||
try {
|
||||
Toast.show({
|
||||
type: 'info',
|
||||
text1: '正在生成分享卡片...',
|
||||
text1: t('challengeDetail.share.generating'),
|
||||
});
|
||||
|
||||
// 捕获分享卡片视图
|
||||
@@ -203,8 +228,8 @@ export default function ChallengeDetailScreen() {
|
||||
|
||||
// 分享图片
|
||||
const shareMessage = isJoined && progress
|
||||
? `我正在参与「${challenge.title}」挑战,已完成 ${progress.completed}/${progress.target} 天!一起加入吧!`
|
||||
: `发现一个很棒的挑战「${challenge.title}」,一起来参与吧!`;
|
||||
? t('challengeDetail.share.messageJoined', { title: challenge.title, completed: progress.completed, target: progress.target })
|
||||
: t('challengeDetail.share.messageNotJoined', { title: challenge.title });
|
||||
|
||||
await Share.share({
|
||||
title: challenge.title,
|
||||
@@ -213,7 +238,7 @@ export default function ChallengeDetailScreen() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('分享失败', error);
|
||||
Toast.error('分享失败,请稍后重试');
|
||||
Toast.error(t('challengeDetail.share.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,7 +259,7 @@ export default function ChallengeDetailScreen() {
|
||||
await dispatch(fetchChallengeRankings({ id }));
|
||||
setShowCelebration(true)
|
||||
} catch (error) {
|
||||
Toast.error('加入挑战失败')
|
||||
Toast.error(t('challengeDetail.alert.joinFailed'))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,7 +271,7 @@ export default function ChallengeDetailScreen() {
|
||||
await dispatch(leaveChallenge(id)).unwrap();
|
||||
await dispatch(fetchChallengeDetail(id)).unwrap();
|
||||
} catch (error) {
|
||||
Toast.error('退出挑战失败');
|
||||
Toast.error(t('challengeDetail.alert.leaveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,34 +279,76 @@ export default function ChallengeDetailScreen() {
|
||||
if (!id || leaveStatus === 'loading') {
|
||||
return;
|
||||
}
|
||||
Alert.alert('确认退出挑战?', '退出后需要重新加入才能继续坚持。', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '退出挑战',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void handleLeave();
|
||||
Alert.alert(
|
||||
t('challengeDetail.alert.leaveConfirm.title'),
|
||||
t('challengeDetail.alert.leaveConfirm.message'),
|
||||
[
|
||||
{ text: t('challengeDetail.alert.leaveConfirm.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('challengeDetail.alert.leaveConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void handleLeave();
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleProgressReport = () => {
|
||||
const handleProgressReport = async () => {
|
||||
if (!id || progressStatus === 'loading') {
|
||||
return;
|
||||
}
|
||||
dispatch(reportChallengeProgress({ id }));
|
||||
|
||||
if (hasCheckedInToday) {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.alreadyChecked'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge?.status === 'upcoming') {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.notStarted'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (challenge?.status === 'expired') {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.expired'));
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isJoined) {
|
||||
Toast.info(t('challengeDetail.checkIn.toast.mustJoin'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(reportChallengeProgress({ id, value: 1 })).unwrap();
|
||||
Toast.success(t('challengeDetail.checkIn.toast.success'));
|
||||
} catch (error) {
|
||||
Toast.error(t('challengeDetail.checkIn.toast.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyShareCode = async () => {
|
||||
if (!challenge?.shareCode) return;
|
||||
await Clipboard.setStringAsync(challenge.shareCode);
|
||||
// 添加震动反馈
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Toast.success(t('challengeDetail.shareCode.copied'));
|
||||
};
|
||||
|
||||
const isJoined = challenge?.isJoined ?? false;
|
||||
const isLoadingInitial = detailStatus === 'loading' && !challenge;
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战,稍后再试试吧。</Text>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.notFound')}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -290,10 +357,10 @@ export default function ChallengeDetailScreen() {
|
||||
if (isLoadingInitial) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<View style={styles.missingContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>加载挑战详情中…</Text>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary, marginTop: 16 }]}>{t('challengeDetail.loading')}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -302,43 +369,43 @@ export default function ChallengeDetailScreen() {
|
||||
if (!challenge) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="挑战详情" onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<HeaderBar title={t('challengeDetail.title')} onBack={() => router.back()} withSafeTop transparent={false} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||
{detailError ?? '未找到该挑战,稍后再试试吧。'}
|
||||
{detailError ?? t('challengeDetail.notFound')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { borderColor: colorTokens.primary }]}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => dispatch(fetchChallengeDetail(id))}
|
||||
>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>重新加载</Text>
|
||||
<Text style={[styles.retryText, { color: colorTokens.primary }]}>{t('challengeDetail.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const highlightTitle = challenge.highlightTitle ?? '立即加入挑战';
|
||||
const highlightSubtitle = challenge.highlightSubtitle ?? '邀请好友一起坚持,更容易收获成果';
|
||||
const joinCtaLabel = joinStatus === 'loading' ? '加入中…' : challenge.ctaLabel ?? '立即加入挑战';
|
||||
const highlightTitle = challenge.highlightTitle ?? t('challengeDetail.highlight.join.title');
|
||||
const highlightSubtitle = challenge.highlightSubtitle ?? t('challengeDetail.highlight.join.subtitle');
|
||||
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
|
||||
const isUpcoming = challenge.status === 'upcoming';
|
||||
const isExpired = challenge.status === 'expired';
|
||||
const upcomingStartLabel = formatMonthDay(challenge.startAt);
|
||||
const upcomingHighlightTitle = '挑战即将开始';
|
||||
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
|
||||
const upcomingHighlightSubtitle = upcomingStartLabel
|
||||
? `${upcomingStartLabel} 开始,敬请期待`
|
||||
: '挑战即将开启,敬请期待';
|
||||
const upcomingCtaLabel = '挑战即将开始';
|
||||
? t('challengeDetail.highlight.upcoming.subtitle', { date: upcomingStartLabel })
|
||||
: t('challengeDetail.highlight.upcoming.subtitleFallback');
|
||||
const upcomingCtaLabel = t('challengeDetail.cta.upcoming');
|
||||
const expiredEndLabel = formatMonthDay(challenge.endAt);
|
||||
const expiredHighlightTitle = '挑战已结束';
|
||||
const expiredHighlightTitle = t('challengeDetail.highlight.expired.title');
|
||||
const expiredHighlightSubtitle = expiredEndLabel
|
||||
? `${expiredEndLabel} 已截止,期待下一次挑战`
|
||||
: '本轮挑战已结束,期待下一次挑战';
|
||||
const expiredCtaLabel = '挑战已结束';
|
||||
const leaveHighlightTitle = '先别急着离开';
|
||||
const leaveHighlightSubtitle = '再坚持一下,下一个里程碑就要出现了';
|
||||
const leaveCtaLabel = leaveStatus === 'loading' ? '退出中…' : '退出挑战';
|
||||
? t('challengeDetail.highlight.expired.subtitle', { date: expiredEndLabel })
|
||||
: t('challengeDetail.highlight.expired.subtitleFallback');
|
||||
const expiredCtaLabel = t('challengeDetail.cta.expired');
|
||||
const leaveHighlightTitle = t('challengeDetail.highlight.leave.title');
|
||||
const leaveHighlightSubtitle = t('challengeDetail.highlight.leave.subtitle');
|
||||
const leaveCtaLabel = leaveStatus === 'loading' ? t('challengeDetail.cta.leaving') : t('challengeDetail.cta.leave');
|
||||
|
||||
let floatingHighlightTitle = highlightTitle;
|
||||
let floatingHighlightSubtitle = highlightSubtitle;
|
||||
@@ -349,8 +416,10 @@ export default function ChallengeDetailScreen() {
|
||||
let isDisabledButtonState = false;
|
||||
|
||||
if (isJoined) {
|
||||
floatingHighlightTitle = leaveHighlightTitle;
|
||||
floatingHighlightSubtitle = leaveHighlightSubtitle;
|
||||
floatingHighlightTitle = showShareCode
|
||||
? `分享码 ${challenge?.shareCode ?? ''}`
|
||||
: leaveHighlightTitle;
|
||||
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
|
||||
floatingCtaLabel = leaveCtaLabel;
|
||||
floatingOnPress = handleLeaveConfirm;
|
||||
floatingDisabled = leaveStatus === 'loading';
|
||||
@@ -380,6 +449,23 @@ export default function ChallengeDetailScreen() {
|
||||
const participantsLabel = formatParticipantsLabel(challenge.participantsCount);
|
||||
|
||||
const inlineErrorMessage = detailStatus === 'failed' && detailError ? detailError : undefined;
|
||||
const checkInDisabled =
|
||||
progressStatus === 'loading' || hasCheckedInToday || !isJoined || isUpcoming || isExpired;
|
||||
const checkInButtonLabel =
|
||||
progressStatus === 'loading'
|
||||
? t('challengeDetail.checkIn.button.checking')
|
||||
: hasCheckedInToday
|
||||
? t('challengeDetail.checkIn.button.checked')
|
||||
: !isJoined
|
||||
? t('challengeDetail.checkIn.button.notJoined')
|
||||
: isUpcoming
|
||||
? t('challengeDetail.checkIn.button.upcoming')
|
||||
: isExpired
|
||||
? t('challengeDetail.checkIn.button.expired')
|
||||
: t('challengeDetail.checkIn.button.checkIn');
|
||||
const checkInSubtitle = hasCheckedInToday
|
||||
? t('challengeDetail.checkIn.subtitleChecked')
|
||||
: t('challengeDetail.checkIn.subtitle');
|
||||
|
||||
return (
|
||||
<View style={styles.safeArea}>
|
||||
@@ -411,9 +497,9 @@ export default function ChallengeDetailScreen() {
|
||||
// 已加入:显示个人进度
|
||||
<View style={styles.shareProgressContainer}>
|
||||
<View style={styles.shareProgressHeader}>
|
||||
<Text style={styles.shareProgressLabel}>我的坚持进度</Text>
|
||||
<Text style={styles.shareProgressLabel}>{t('challengeDetail.shareCard.progress.label')}</Text>
|
||||
<Text style={styles.shareProgressValue}>
|
||||
{progress.completed} / {progress.target} 天
|
||||
{t('challengeDetail.shareCard.progress.days', { completed: progress.completed, target: progress.target })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -429,8 +515,8 @@ export default function ChallengeDetailScreen() {
|
||||
|
||||
<Text style={styles.shareProgressSubtext}>
|
||||
{progress.completed === progress.target
|
||||
? '🎉 已完成挑战!'
|
||||
: `还差 ${progress.target - progress.completed} 天完成挑战`}
|
||||
? t('challengeDetail.shareCard.progress.completed')
|
||||
: t('challengeDetail.shareCard.progress.remaining', { remaining: progress.target - progress.completed })}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@@ -454,7 +540,7 @@ export default function ChallengeDetailScreen() {
|
||||
</View>
|
||||
<View style={styles.shareInfoTextWrapper}>
|
||||
<Text style={styles.shareInfoLabel}>{challenge.requirementLabel}</Text>
|
||||
<Text style={styles.shareInfoMeta}>按日打卡自动累计</Text>
|
||||
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.checkInDaily')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -464,7 +550,7 @@ export default function ChallengeDetailScreen() {
|
||||
</View>
|
||||
<View style={styles.shareInfoTextWrapper}>
|
||||
<Text style={styles.shareInfoLabel}>{participantsLabel}</Text>
|
||||
<Text style={styles.shareInfoMeta}>快来一起坚持吧</Text>
|
||||
<Text style={styles.shareInfoMeta}>{t('challengeDetail.shareCard.info.joinUs')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -472,7 +558,7 @@ export default function ChallengeDetailScreen() {
|
||||
|
||||
{/* 底部标识 */}
|
||||
<View style={styles.shareCardFooter}>
|
||||
<Text style={styles.shareCardFooterText}>Out Live · 超越生命</Text>
|
||||
<Text style={styles.shareCardFooterText}>{t('challengeDetail.shareCard.footer')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -568,7 +654,7 @@ export default function ChallengeDetailScreen() {
|
||||
</View>
|
||||
<View style={styles.detailTextWrapper}>
|
||||
<Text style={styles.detailLabel}>{challenge.requirementLabel}</Text>
|
||||
<Text style={styles.detailMeta}>按日打卡自动累计</Text>
|
||||
<Text style={styles.detailMeta}>{t('challengeDetail.detail.requirement')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -590,19 +676,50 @@ export default function ChallengeDetailScreen() {
|
||||
))}
|
||||
{challenge.participantsCount && challenge.participantsCount > participantAvatars.length ? (
|
||||
<TouchableOpacity style={styles.moreAvatarButton}>
|
||||
<Text style={styles.moreAvatarText}>更多</Text>
|
||||
<Text style={styles.moreAvatarText}>{t('challengeDetail.participants.more')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCustomChallenge ? (
|
||||
<View style={styles.checkInCard}>
|
||||
<View style={styles.checkInCopy}>
|
||||
<Text style={styles.checkInTitle}>{hasCheckedInToday ? t('challengeDetail.checkIn.todayChecked') : t('challengeDetail.checkIn.title')}</Text>
|
||||
<Text style={styles.checkInSubtitle}>{checkInSubtitle}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleProgressReport}
|
||||
disabled={checkInDisabled}
|
||||
style={styles.checkInButton}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={checkInDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.checkInButtonBackground}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.checkInButtonLabel,
|
||||
checkInDisabled && styles.checkInButtonLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{checkInButtonLabel}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>排行榜</Text>
|
||||
<Text style={styles.sectionTitle}>{t('challengeDetail.ranking.title')}</Text>
|
||||
<TouchableOpacity activeOpacity={0.8} onPress={handleViewAllRanking}>
|
||||
<Text style={styles.sectionAction}>查看全部</Text>
|
||||
<Text style={styles.sectionAction}>{t('challengeDetail.detail.viewAllRanking')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -623,7 +740,7 @@ export default function ChallengeDetailScreen() {
|
||||
))
|
||||
) : (
|
||||
<View style={styles.emptyRanking}>
|
||||
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||
<Text style={styles.emptyRankingText}>{t('challengeDetail.ranking.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -632,11 +749,30 @@ export default function ChallengeDetailScreen() {
|
||||
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}>
|
||||
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}>
|
||||
<View style={styles.floatingCTAContent}>
|
||||
<View style={styles.highlightCopy}>
|
||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||
</View>
|
||||
{showShareCode ? (
|
||||
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
|
||||
<View style={styles.shareCodeRow}>
|
||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.shareCodeIconButton}
|
||||
onPress={handleCopyShareCode}
|
||||
>
|
||||
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{floatingHighlightSubtitle ? (
|
||||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||
) : null}
|
||||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.highlightCopy}>
|
||||
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
|
||||
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
|
||||
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.highlightButton}
|
||||
activeOpacity={0.9}
|
||||
@@ -732,6 +868,19 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
highlightCopyCompact: {
|
||||
marginRight: 12,
|
||||
gap: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
shareCodeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flex: 1,
|
||||
},
|
||||
headerTextBlock: {
|
||||
paddingHorizontal: 24,
|
||||
marginTop: HERO_HEIGHT - 60,
|
||||
@@ -834,6 +983,49 @@ const styles = StyleSheet.create({
|
||||
color: '#4F5BD5',
|
||||
fontWeight: '600',
|
||||
},
|
||||
checkInCard: {
|
||||
marginTop: 4,
|
||||
padding: 14,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#f5f6ff',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
checkInCopy: {
|
||||
flex: 1,
|
||||
},
|
||||
checkInTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
checkInSubtitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6f7ba7',
|
||||
lineHeight: 18,
|
||||
},
|
||||
checkInButton: {
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
checkInButtonBackground: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 18,
|
||||
minWidth: 96,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkInButtonLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
color: '#ffffff',
|
||||
},
|
||||
checkInButtonLabelDisabled: {
|
||||
color: '#6f7799',
|
||||
},
|
||||
sectionHeader: {
|
||||
marginTop: 36,
|
||||
marginHorizontal: 24,
|
||||
@@ -889,6 +1081,10 @@ const styles = StyleSheet.create({
|
||||
color: '#5f6a97',
|
||||
lineHeight: 18,
|
||||
},
|
||||
shareCodeIconButton: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
ctaErrorText: {
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
@@ -1084,4 +1280,3 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
976
app/challenges/create-custom.tsx
Normal file
@@ -0,0 +1,976 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi';
|
||||
import {
|
||||
createCustomChallengeThunk,
|
||||
fetchChallenges,
|
||||
selectCreateChallengeError,
|
||||
selectCreateChallengeStatus,
|
||||
} from '@/store/challengesSlice';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
|
||||
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [
|
||||
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' },
|
||||
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' },
|
||||
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' },
|
||||
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' },
|
||||
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' },
|
||||
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' },
|
||||
];
|
||||
|
||||
const FALLBACK_IMAGE =
|
||||
'https://images.unsplash.com/photo-1506126613408-eca07ce68773?auto=format&fit=crop&w=1200&q=80';
|
||||
|
||||
type PickerType = 'start' | 'end' | null;
|
||||
|
||||
export default function CreateCustomChallengeScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const createStatus = useAppSelector(selectCreateChallengeStatus);
|
||||
const createError = useAppSelector(selectCreateChallengeError);
|
||||
const isCreating = createStatus === 'loading';
|
||||
|
||||
const today = useMemo(() => dayjs().startOf('day').toDate(), []);
|
||||
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [image, setImage] = useState<string | undefined>(FALLBACK_IMAGE);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const { upload, uploading } = useCosUpload({ prefix: 'images/challenges' });
|
||||
const [type, setType] = useState<ChallengeType>(ChallengeType.WATER);
|
||||
const [startDate, setStartDate] = useState<Date>(today);
|
||||
const [endDate, setEndDate] = useState<Date>(defaultEnd);
|
||||
const [targetValue, setTargetValue] = useState('');
|
||||
const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
|
||||
const [requirementLabel, setRequirementLabel] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [progressUnit] = useState('天');
|
||||
const [periodLabel, setPeriodLabel] = useState('');
|
||||
const [periodEdited, setPeriodEdited] = useState(false);
|
||||
const [rankingDescription] = useState('连续打卡榜');
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [maxParticipants, setMaxParticipants] = useState('100');
|
||||
const [minimumEdited, setMinimumEdited] = useState(false);
|
||||
|
||||
const [shareCode, setShareCode] = useState<string | null>(null);
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false);
|
||||
const [createdChallengeId, setCreatedChallengeId] = useState<string | null>(null);
|
||||
|
||||
const [pickerType, setPickerType] = useState<PickerType>(null);
|
||||
|
||||
const durationDays = useMemo(
|
||||
() =>
|
||||
Math.max(
|
||||
1,
|
||||
dayjs(endDate).startOf('day').diff(dayjs(startDate).startOf('day'), 'day') + 1
|
||||
),
|
||||
[startDate, endDate]
|
||||
);
|
||||
const durationLabel = useMemo(() => `持续${durationDays}天`, [durationDays]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!periodEdited) {
|
||||
setPeriodLabel(`${durationDays}天挑战`);
|
||||
}
|
||||
if (!minimumEdited) {
|
||||
setMinimumCheckInDays(String(durationDays));
|
||||
}
|
||||
}, [durationDays, minimumEdited, periodEdited]);
|
||||
|
||||
const handleConfirmDate = (date: Date) => {
|
||||
if (!pickerType) return;
|
||||
const normalized = dayjs(date).startOf('day');
|
||||
if (pickerType === 'start') {
|
||||
const nextStart = normalized.isAfter(dayjs(), 'day')
|
||||
? normalized
|
||||
: dayjs().add(1, 'day').startOf('day');
|
||||
setStartDate(nextStart.toDate());
|
||||
if (dayjs(endDate).isSameOrBefore(nextStart)) {
|
||||
const nextEnd = nextStart.add(20, 'day').toDate();
|
||||
setEndDate(nextEnd);
|
||||
}
|
||||
} else {
|
||||
const minEnd = dayjs(startDate).add(1, 'day').startOf('day');
|
||||
const nextEnd = normalized.isAfter(minEnd) ? normalized : minEnd;
|
||||
setEndDate(nextEnd.toDate());
|
||||
}
|
||||
setPickerType(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isCreating) return;
|
||||
if (!title.trim()) {
|
||||
Toast.warning('请填写挑战标题');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requirementLabel.trim()) {
|
||||
Toast.warning('请填写挑战要求说明');
|
||||
return;
|
||||
}
|
||||
|
||||
const startTimestamp = dayjs(startDate).valueOf();
|
||||
const endTimestamp = dayjs(endDate).valueOf();
|
||||
if (endTimestamp <= startTimestamp) {
|
||||
Toast.warning('结束时间需要晚于开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = Number(targetValue);
|
||||
if (!Number.isFinite(target) || target < 1 || target > 1000) {
|
||||
Toast.warning('每日目标值需在 1-1000 之间');
|
||||
return;
|
||||
}
|
||||
|
||||
const minDays = Number(minimumCheckInDays) || durationDays;
|
||||
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
|
||||
Toast.warning('最少打卡天数需在 1-365 之间');
|
||||
return;
|
||||
}
|
||||
if (minDays > durationDays) {
|
||||
Toast.warning('最少打卡天数不能超过持续天数');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxP = maxParticipants ? Number(maxParticipants) : null;
|
||||
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
|
||||
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制');
|
||||
return;
|
||||
}
|
||||
|
||||
const safeTitle = title.trim() || '自定义挑战';
|
||||
const payload: CreateCustomChallengePayload = {
|
||||
title: safeTitle,
|
||||
type,
|
||||
image: image?.trim() || undefined,
|
||||
startAt: startTimestamp,
|
||||
endAt: endTimestamp,
|
||||
targetValue: target,
|
||||
minimumCheckInDays: minDays,
|
||||
durationLabel,
|
||||
requirementLabel: requirementLabel.trim() || '请填写挑战要求',
|
||||
summary: summary.trim() || undefined,
|
||||
progressUnit: progressUnit.trim() || '天',
|
||||
periodLabel: periodLabel.trim() || undefined,
|
||||
rankingDescription: rankingDescription.trim() || undefined,
|
||||
isPublic,
|
||||
maxParticipants: maxP,
|
||||
};
|
||||
|
||||
try {
|
||||
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
|
||||
setShareCode(created.shareCode ?? null);
|
||||
setCreatedChallengeId(created.id);
|
||||
setShareModalVisible(true);
|
||||
Toast.success('自定义挑战已创建');
|
||||
dispatch(fetchChallenges());
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : '创建失败,请稍后再试';
|
||||
Toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyShareCode = async () => {
|
||||
if (!shareCode) return;
|
||||
await Clipboard.setStringAsync(shareCode);
|
||||
Toast.success('邀请码已复制');
|
||||
};
|
||||
|
||||
const handleTargetInputChange = (value: string) => {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setTargetValue('');
|
||||
return;
|
||||
}
|
||||
const num = Math.min(1000, parseInt(digits, 10));
|
||||
setTargetValue(String(num));
|
||||
};
|
||||
|
||||
const handleMinimumDaysChange = (value: string) => {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setMinimumCheckInDays('');
|
||||
setMinimumEdited(true);
|
||||
return;
|
||||
}
|
||||
const num = Math.max(1, Math.min(365, parseInt(digits, 10)));
|
||||
if (num > durationDays) {
|
||||
setMinimumCheckInDays(String(durationDays));
|
||||
setMinimumEdited(true);
|
||||
return;
|
||||
}
|
||||
setMinimumEdited(true);
|
||||
setMinimumCheckInDays(String(num));
|
||||
};
|
||||
|
||||
const handlePickImage = useCallback(() => {
|
||||
Alert.alert(
|
||||
'选择封面图',
|
||||
'请选择封面来源',
|
||||
[
|
||||
{
|
||||
text: '拍照',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相机权限以拍摄封面');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.6,
|
||||
aspect: [16, 9],
|
||||
});
|
||||
if (result.canceled || !result.assets?.length) return;
|
||||
const asset = result.assets[0];
|
||||
setImagePreview(asset.uri);
|
||||
setImage(undefined);
|
||||
try {
|
||||
const { url } = await upload(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
|
||||
type: asset.mimeType ?? 'image/jpeg',
|
||||
},
|
||||
{ prefix: 'images/challenges' }
|
||||
);
|
||||
setImage(url);
|
||||
setImagePreview(null);
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 封面上传失败', error);
|
||||
Alert.alert('上传失败', '封面上传失败,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 拍照失败', error);
|
||||
Alert.alert('拍照失败', '无法打开相机,请稍后再试');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: '从相册选择',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相册权限以选择封面');
|
||||
return;
|
||||
}
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
quality: 0.9,
|
||||
});
|
||||
if (result.canceled || !result.assets?.length) return;
|
||||
const asset = result.assets[0];
|
||||
setImagePreview(asset.uri);
|
||||
setImage(undefined);
|
||||
try {
|
||||
const { url } = await upload(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.fileName ?? `challenge-${Date.now()}.jpg`,
|
||||
type: asset.mimeType ?? 'image/jpeg',
|
||||
},
|
||||
{ prefix: 'images/challenges' }
|
||||
);
|
||||
setImage(url);
|
||||
setImagePreview(null);
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 封面上传失败', error);
|
||||
Alert.alert('上传失败', '封面上传失败,请稍后重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CHALLENGE] 选择封面失败', error);
|
||||
Alert.alert('选择失败', '无法打开相册,请稍后再试');
|
||||
}
|
||||
},
|
||||
},
|
||||
{ text: '取消', style: 'cancel' },
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
}, [upload]);
|
||||
|
||||
const handleViewChallenge = () => {
|
||||
setShareModalVisible(false);
|
||||
if (createdChallengeId) {
|
||||
router.replace({ pathname: '/challenges/[id]', params: { id: createdChallengeId } });
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (val: string) => void,
|
||||
placeholder?: string,
|
||||
keyboardType: 'default' | 'numeric' = 'default',
|
||||
onFocus?: () => void
|
||||
) => (
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={styles.input}
|
||||
keyboardType={keyboardType}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderTextarea = (
|
||||
label: string,
|
||||
value: string,
|
||||
onChange: (val: string) => void,
|
||||
placeholder?: string
|
||||
) => (
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9ca3af"
|
||||
style={[styles.input, styles.textarea]}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const progressMeta = `${durationDays} 天 · ${progressUnit || '天'}`;
|
||||
const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<LinearGradient
|
||||
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<HeaderBar title="新建挑战" transparent />
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={80}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: (Platform.OS === 'ios' ? 180 : 140) + insets.bottom },
|
||||
]}
|
||||
>
|
||||
<View style={styles.heroContainer}>
|
||||
<Image
|
||||
source={{ uri: heroImageSource }}
|
||||
style={styles.heroImage}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.35)', 'rgba(0,0,0,0.15)', 'rgba(244, 246, 255, 1)']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
<View style={styles.heroOverlay}>
|
||||
<Text style={styles.heroKicker}>自定义挑战</Text>
|
||||
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text>
|
||||
<Text style={styles.heroMeta}>{progressMeta}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<View style={styles.formHeader}>
|
||||
<Text style={styles.sectionTitle}>基础信息</Text>
|
||||
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null}
|
||||
</View>
|
||||
{renderField('标题', title, setTitle, '挑战标题(最多100字)')}
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>封面图</Text>
|
||||
<View style={styles.uploadRow}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={[styles.uploadButton, uploading && styles.uploadButtonDisabled]}
|
||||
onPress={handlePickImage}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text>
|
||||
</TouchableOpacity>
|
||||
{image || imagePreview ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
setImagePreview(null);
|
||||
setImage(undefined);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.clearUpload}>清除</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={styles.helperText}>建议比例 16:9,清晰展示挑战氛围</Text>
|
||||
</View>
|
||||
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')}
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>挑战设置</Text>
|
||||
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>挑战类型</Text>
|
||||
<View style={styles.chipRow}>
|
||||
{typeOptions.map((option) => {
|
||||
const active = option.value === type;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => setType(option.value)}
|
||||
style={[
|
||||
styles.chip,
|
||||
active && { backgroundColor: `${option.accent}1A`, borderColor: option.accent },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.chipLabel,
|
||||
active && { color: option.accent, fontWeight: '700' },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>时间范围</Text>
|
||||
<View style={styles.dateRow}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.datePill}
|
||||
onPress={() => setPickerType('start')}
|
||||
>
|
||||
<Text style={styles.dateLabel}>开始</Text>
|
||||
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.datePill}
|
||||
onPress={() => setPickerType('end')}
|
||||
>
|
||||
<Text style={styles.dateLabel}>结束</Text>
|
||||
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inlineFields}>
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>持续时间</Text>
|
||||
<View style={styles.readonlyPill}>
|
||||
<Text style={styles.readonlyText}>{durationLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{renderField('周期标签', periodLabel, (v) => {
|
||||
setPeriodEdited(true);
|
||||
setPeriodLabel(v);
|
||||
}, '如:21天挑战')}
|
||||
</View>
|
||||
|
||||
<View style={styles.inlineFields}>
|
||||
{renderField('每日目标值', targetValue, handleTargetInputChange, '如:8', 'numeric')}
|
||||
<View style={styles.fieldBlock}>
|
||||
<Text style={styles.fieldLabel}>进度单位</Text>
|
||||
<View style={styles.readonlyPill}>
|
||||
<Text style={styles.readonlyText}>{progressUnit}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')}
|
||||
|
||||
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')}
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.sectionTitle}>展示&互动</Text>
|
||||
<View style={styles.inlineFields}>
|
||||
{renderField('参与人数上限', maxParticipants, (v) => {
|
||||
const digits = v.replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
setMaxParticipants('');
|
||||
return;
|
||||
}
|
||||
setMaxParticipants(String(parseInt(digits, 10)));
|
||||
}, '留空表示无限制', 'numeric')}
|
||||
</View>
|
||||
<View style={styles.switchRow}>
|
||||
<View>
|
||||
<Text style={styles.fieldLabel}>是否公开</Text>
|
||||
<Text style={styles.switchHint}>公开后其他用户可通过邀请码加入</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isPublic}
|
||||
onValueChange={setIsPublic}
|
||||
trackColor={{ true: colorTokens.primary, false: '#cbd5e1' }}
|
||||
thumbColor={isPublic ? '#ffffff' : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<View pointerEvents="box-none" style={[styles.floatingCTA, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<BlurView intensity={14} tint="light" style={styles.floatingBlur}>
|
||||
<View style={styles.floatingContent}>
|
||||
<View style={styles.floatingCopy}>
|
||||
<Text style={styles.floatingTitle}>生成自定义挑战</Text>
|
||||
<Text style={styles.floatingSubtitle}>自动创建分享码,邀请好友一起挑战</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.floatingButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#5E8BFF', '#6B6CFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.floatingButtonBackground}
|
||||
>
|
||||
<Text style={styles.floatingButtonLabel}>
|
||||
{isCreating ? '创建中…' : '创建并生成邀请码'}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BlurView>
|
||||
</View>
|
||||
|
||||
<DateTimePickerModal
|
||||
isVisible={pickerType !== null}
|
||||
mode="date"
|
||||
date={pickerType === 'end' ? endDate : startDate}
|
||||
minimumDate={pickerType === 'end' ? dayjs(startDate).add(1, 'day').toDate() : dayjs().add(1, 'day').toDate()}
|
||||
onConfirm={handleConfirmDate}
|
||||
onCancel={() => setPickerType(null)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
visible={shareModalVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShareModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.shareCard}>
|
||||
<Text style={styles.shareTitle}>邀请码已生成</Text>
|
||||
<Text style={styles.shareSubtitle}>分享给好友即可加入挑战</Text>
|
||||
<View style={styles.shareCodeBadge}>
|
||||
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text>
|
||||
</View>
|
||||
<View style={styles.shareActions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={styles.shareButtonGhost}
|
||||
onPress={handleCopyShareCode}
|
||||
disabled={!shareCode}
|
||||
>
|
||||
<Text style={styles.shareButtonGhostLabel}>复制邀请码</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
style={styles.shareButtonPrimary}
|
||||
onPress={handleViewChallenge}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#5E8BFF', '#6B6CFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.shareButtonPrimary}
|
||||
>
|
||||
<Text style={styles.shareButtonPrimaryLabel}>查看挑战</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.shareClose}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => setShareModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.shareCloseLabel}>稍后再说</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 160,
|
||||
},
|
||||
heroContainer: {
|
||||
height: 260,
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroOverlay: {
|
||||
position: 'absolute',
|
||||
bottom: 22,
|
||||
left: 20,
|
||||
right: 20,
|
||||
},
|
||||
heroKicker: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 13,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: '700',
|
||||
},
|
||||
heroTitle: {
|
||||
marginTop: 8,
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
textShadowColor: 'rgba(0,0,0,0.25)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 6,
|
||||
},
|
||||
heroMeta: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
color: '#e2e8f0',
|
||||
fontWeight: '600',
|
||||
},
|
||||
formCard: {
|
||||
marginTop: 14,
|
||||
marginHorizontal: 20,
|
||||
padding: 18,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.12)',
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 8,
|
||||
gap: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
fieldBlock: {
|
||||
gap: 6,
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
input: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontSize: 15,
|
||||
color: '#111827',
|
||||
},
|
||||
textarea: {
|
||||
minHeight: 90,
|
||||
},
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f8fafc',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
},
|
||||
chipLabel: {
|
||||
fontSize: 13,
|
||||
color: '#334155',
|
||||
},
|
||||
uploadRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
uploadButton: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#EEF1FF',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
},
|
||||
uploadButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
uploadButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#4F5BD5',
|
||||
},
|
||||
clearUpload: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#9ca3af',
|
||||
},
|
||||
helperText: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
dateRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
datePill: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
dateValue: {
|
||||
marginTop: 4,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
readonlyPill: {
|
||||
marginTop: 6,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
readonlyText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
inlineFields: {
|
||||
gap: 12,
|
||||
},
|
||||
switchRow: {
|
||||
marginTop: 6,
|
||||
padding: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
switchHint: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
formHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
inlineError: {
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
floatingCTA: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 10,
|
||||
},
|
||||
floatingBlur: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
backgroundColor: 'rgba(243, 244, 251, 0.9)',
|
||||
},
|
||||
floatingContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
floatingCopy: {
|
||||
flex: 1,
|
||||
},
|
||||
floatingTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
floatingSubtitle: {
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
floatingButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
floatingButtonBackground: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
floatingButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
shareCard: {
|
||||
width: '100%',
|
||||
padding: 20,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(15, 23, 42, 0.18)',
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 20,
|
||||
elevation: 12,
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
shareTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#0f172a',
|
||||
},
|
||||
shareSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
},
|
||||
shareCodeBadge: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#EEF1FF',
|
||||
},
|
||||
shareCode: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: '#4F5BD5',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
shareActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
},
|
||||
shareButtonGhost: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
},
|
||||
shareButtonGhostLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#475569',
|
||||
},
|
||||
shareButtonPrimary: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shareButtonPrimaryLabel: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
color: '#ffffff',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
shareClose: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
shareCloseLabel: {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { ThemedView } from '@/components/ThemedView';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { usePushNotifications } from '@/hooks/usePushNotifications';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { preloadUserData } from '@/store/userSlice';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
@@ -19,10 +20,11 @@ export default function SplashScreen() {
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
// 先预加载用户数据,包括 onboarding 状态
|
||||
console.log('开始预加载用户数据(包含 onboarding 状态)...');
|
||||
const userData = await preloadUserData();
|
||||
console.log('用户数据预加载完成,onboarding 状态:', userData.onboardingCompleted);
|
||||
// 直接读取 onboarding 状态
|
||||
console.log('检查 onboarding 状态...');
|
||||
const onboardingCompletedStr = await AsyncStorage.getItem(STORAGE_KEYS.onboardingCompleted);
|
||||
const onboardingCompleted = onboardingCompletedStr === 'true';
|
||||
console.log('Onboarding 状态:', onboardingCompleted);
|
||||
|
||||
// 初始化推送通知(不阻塞应用启动,且不会请求权限)
|
||||
console.log('开始初始化推送通知基础服务...');
|
||||
@@ -30,8 +32,8 @@ export default function SplashScreen() {
|
||||
console.warn('推送通知初始化失败,但不影响应用正常使用:', error);
|
||||
});
|
||||
|
||||
// 根据预加载的状态决定跳转
|
||||
if (userData.onboardingCompleted) {
|
||||
// 根据状态决定跳转
|
||||
if (onboardingCompleted) {
|
||||
console.log('用户已完成引导,跳转到统计页面');
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
} else {
|
||||
@@ -39,7 +41,7 @@ export default function SplashScreen() {
|
||||
router.replace(ROUTES.ONBOARDING);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查引导状态或预加载用户数据失败:', error);
|
||||
console.error('检查引导状态失败:', error);
|
||||
// 如果出现错误,默认进入主应用(假设已完成引导)
|
||||
router.replace(ROUTES.TAB_STATISTICS);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
|
||||
import type { MedicationForm, RepeatPattern } from '@/types/medication';
|
||||
import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
@@ -151,10 +150,13 @@ export default function AddMedicationScreen() {
|
||||
const [timesPickerValue, setTimesPickerValue] = useState(1);
|
||||
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||
const [endDate, setEndDate] = useState<Date | null>(null);
|
||||
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
|
||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||
const [datePickerValue, setDatePickerValue] = useState<Date>(new Date());
|
||||
const [endDatePickerVisible, setEndDatePickerVisible] = useState(false);
|
||||
const [endDatePickerValue, setEndDatePickerValue] = useState<Date>(new Date());
|
||||
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
|
||||
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
|
||||
const [datePickerMode, setDatePickerMode] = useState<'start' | 'end'>('start');
|
||||
const [medicationTimes, setMedicationTimes] = useState<string[]>([DEFAULT_TIME_PRESETS[0]]);
|
||||
const [timePickerVisible, setTimePickerVisible] = useState(false);
|
||||
@@ -319,6 +321,7 @@ export default function AddMedicationScreen() {
|
||||
medicationTimes: medicationTimes,
|
||||
startDate: dayjs(startDate).startOf('day').toISOString(), // ISO 8601 格式
|
||||
endDate: endDate ? dayjs(endDate).endOf('day').toISOString() : undefined, // 如果有结束日期,设置为当天结束时间
|
||||
expiryDate: expiryDate ? dayjs(expiryDate).endOf('day').toISOString() : undefined, // 如果有有效期,设置为当天结束时间
|
||||
repeatPattern: 'daily' as RepeatPattern,
|
||||
note: note.trim() || undefined,
|
||||
};
|
||||
@@ -333,16 +336,6 @@ export default function AddMedicationScreen() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
await dispatch(fetchMedicationRecords({ date: today }));
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
// 获取最新的药品列表
|
||||
const medications = await dispatch(fetchMedications({ isActive: true })).unwrap();
|
||||
await medicationNotificationService.rescheduleAllMedicationNotifications(medications);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||
// 不影响添加药品的成功流程,只记录错误
|
||||
}
|
||||
|
||||
// 成功提示
|
||||
Alert.alert(
|
||||
'添加成功',
|
||||
@@ -531,6 +524,11 @@ export default function AddMedicationScreen() {
|
||||
setEndDatePickerVisible(true);
|
||||
}, [endDate]);
|
||||
|
||||
const openExpiryDatePicker = useCallback(() => {
|
||||
setExpiryDatePickerValue(expiryDate || new Date());
|
||||
setExpiryDatePickerVisible(true);
|
||||
}, [expiryDate]);
|
||||
|
||||
const confirmStartDate = useCallback((date: Date) => {
|
||||
// 验证开始日期不能早于今天
|
||||
const today = new Date();
|
||||
@@ -563,6 +561,22 @@ export default function AddMedicationScreen() {
|
||||
setEndDatePickerVisible(false);
|
||||
}, [startDate]);
|
||||
|
||||
const confirmExpiryDate = useCallback((date: Date) => {
|
||||
// 验证有效期不能早于今天
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const selectedDate = new Date(date);
|
||||
selectedDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (selectedDate < today) {
|
||||
Alert.alert('日期无效', '有效期不能早于今天');
|
||||
return;
|
||||
}
|
||||
|
||||
setExpiryDate(date);
|
||||
setExpiryDatePickerVisible(false);
|
||||
}, []);
|
||||
|
||||
const openTimePicker = useCallback(
|
||||
(index?: number) => {
|
||||
try {
|
||||
@@ -872,6 +886,32 @@ export default function AddMedicationScreen() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<View style={styles.periodHeader}>
|
||||
<ThemedText style={[styles.groupLabel, { color: colors.textSecondary }]}>药品有效期</ThemedText>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.dateRow,
|
||||
{
|
||||
borderColor: softBorderColor,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
]}
|
||||
onPress={openExpiryDatePicker}
|
||||
>
|
||||
<View style={styles.dateLeft}>
|
||||
<Ionicons name="time-outline" size={16} color={colors.textSecondary} />
|
||||
<ThemedText style={[styles.dateLabel, { color: colors.textMuted }]}>有效期至</ThemedText>
|
||||
<ThemedText style={[styles.dateValue, { color: colors.text }]}>
|
||||
{expiryDate ? dayjs(expiryDate).format('YYYY/MM/DD') : '未设置'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
case 3:
|
||||
@@ -1166,6 +1206,51 @@ export default function AddMedicationScreen() {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={expiryDatePickerVisible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setExpiryDatePickerVisible(false)}
|
||||
>
|
||||
<Pressable style={styles.pickerBackdrop} onPress={() => setExpiryDatePickerVisible(false)} />
|
||||
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
|
||||
>
|
||||
<ThemedText style={[styles.modalTitle, { color: colors.text }]}>选择药品有效期</ThemedText>
|
||||
<DateTimePicker
|
||||
value={expiryDatePickerValue}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setExpiryDatePickerValue(date);
|
||||
} else {
|
||||
if (event.type === 'set' && date) {
|
||||
confirmExpiryDate(date);
|
||||
} else {
|
||||
setExpiryDatePickerVisible(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable
|
||||
onPress={() => setExpiryDatePickerVisible(false)}
|
||||
style={[styles.modalBtn, { borderColor: softBorderColor }]}
|
||||
>
|
||||
<ThemedText style={[styles.modalBtnText, { color: colors.textSecondary }]}>取消</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => confirmExpiryDate(expiryDatePickerValue)}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
<ThemedText style={[styles.modalBtnText, { color: colors.onPrimary }]}>确定</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={endDatePickerVisible}
|
||||
transparent
|
||||
|
||||
943
app/medications/ai-camera.tsx
Normal file
@@ -0,0 +1,943 @@
|
||||
import { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { createMedicationRecognitionTask } from '@/services/medications';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Dimensions,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
// 本地存储的 key,用于记录用户是否已经看过拍摄引导
|
||||
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
|
||||
|
||||
const captureSteps = [
|
||||
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
|
||||
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
|
||||
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
|
||||
] as const;
|
||||
|
||||
type CaptureKey = (typeof captureSteps)[number]['key'];
|
||||
|
||||
type Shot = {
|
||||
uri: string;
|
||||
};
|
||||
|
||||
export default function MedicationAiCameraScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' });
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [facing, setFacing] = useState<'back' | 'front'>('back');
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
|
||||
front: null,
|
||||
side: null,
|
||||
aux: null,
|
||||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [showGuideModal, setShowGuideModal] = useState(false);
|
||||
|
||||
// 动画控制:0 = 圆形拍摄按钮,1 = 展开为两个按钮
|
||||
const expandAnimation = useSharedValue(0);
|
||||
|
||||
// 首次进入时显示引导弹窗
|
||||
useEffect(() => {
|
||||
const checkAndShowGuide = async () => {
|
||||
try {
|
||||
// 从本地存储读取是否已经看过引导
|
||||
const hasSeenGuide = await getItem(MEDICATION_GUIDE_SEEN_KEY);
|
||||
|
||||
// 如果没有看过(返回 null 或 undefined),则显示引导弹窗
|
||||
if (!hasSeenGuide) {
|
||||
setShowGuideModal(true);
|
||||
// 标记为已看过,下次进入不再自动显示
|
||||
await setItem(MEDICATION_GUIDE_SEEN_KEY, 'true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] 检查引导状态失败', error);
|
||||
// 出错时为了更好的用户体验,还是显示引导
|
||||
setShowGuideModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkAndShowGuide();
|
||||
}, []);
|
||||
|
||||
const currentStep = captureSteps[currentStepIndex];
|
||||
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
|
||||
const allRequiredCaptured = Boolean(shots.front && shots.side);
|
||||
|
||||
// 当必需照片都拍摄完成后,触发展开动画
|
||||
useEffect(() => {
|
||||
if (allRequiredCaptured) {
|
||||
expandAnimation.value = withTiming(1, {
|
||||
duration: 350,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
} else {
|
||||
expandAnimation.value = withTiming(0, {
|
||||
duration: 300,
|
||||
easing: Easing.inOut(Easing.cubic),
|
||||
});
|
||||
}
|
||||
}, [allRequiredCaptured]);
|
||||
|
||||
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
|
||||
|
||||
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动
|
||||
const cameraHeight = useMemo(() => {
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
// 计算固定占用的高度(使用最大值确保布局稳定)
|
||||
const headerHeight = insets.top + 40; // HeaderBar 高度
|
||||
const topMetaHeight = 12 + 28 + 26 + 16 + 6; // topMeta 区域:padding + badge + title + subtitle + gap
|
||||
const shotsRowHeight = 12 + 88; // shotsRow 区域:paddingTop + shotCard 高度
|
||||
// 固定使用展开状态的高度,确保布局不会跳动
|
||||
const bottomBarHeight = 12 + 86 + 10 + Math.max(insets.bottom, 20); // bottomBar 区域(不包含动态变化部分)
|
||||
const margins = 12 + 12; // cameraCard 的上下边距
|
||||
|
||||
// 可用于相机的高度 = 屏幕高度 - 所有固定元素高度
|
||||
const availableHeight = screenHeight - headerHeight - topMetaHeight - shotsRowHeight - bottomBarHeight - margins;
|
||||
|
||||
// 确保最小高度为 300,最大不超过屏幕的 50%
|
||||
return Math.max(300, Math.min(availableHeight, screenHeight * 0.5));
|
||||
}, [insets.top, insets.bottom]);
|
||||
|
||||
const handleToggleCamera = () => {
|
||||
setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
|
||||
const handlePickFromAlbum = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets?.length) {
|
||||
const asset = result.assets[0];
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
goNextStep();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] pick image failed', error);
|
||||
Alert.alert('选择失败', '请重试或更换图片');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakePicture = async () => {
|
||||
if (!cameraRef.current) return;
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
|
||||
if (photo?.uri) {
|
||||
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
|
||||
|
||||
// 拍摄完成后自动进入下一步(如果还有下一步)
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setTimeout(() => {
|
||||
goNextStep();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_AI] take picture failed', error);
|
||||
Alert.alert('拍摄失败', '请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const goNextStep = () => {
|
||||
if (currentStepIndex < captureSteps.length - 1) {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartRecognition = async () => {
|
||||
// 检查必需照片是否完成
|
||||
if (!allRequiredCaptured) {
|
||||
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
|
||||
return;
|
||||
}
|
||||
|
||||
await startRecognition();
|
||||
};
|
||||
|
||||
const startRecognition = async () => {
|
||||
if (!shots.front || !shots.side) return;
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
|
||||
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
|
||||
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const task = await createMedicationRecognitionTask({
|
||||
frontImageUrl: frontUpload.url,
|
||||
sideImageUrl: sideUpload.url,
|
||||
auxiliaryImageUrl: auxUpload?.url,
|
||||
});
|
||||
|
||||
router.replace({
|
||||
pathname: '/medications/ai-progress',
|
||||
params: {
|
||||
taskId: task.taskId,
|
||||
cover: frontUpload.url,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[MEDICATION_AI] recognize failed', error);
|
||||
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 动画翻转按钮组件
|
||||
const AnimatedToggleButton = ({
|
||||
expandAnimation,
|
||||
onPress,
|
||||
disabled,
|
||||
}: {
|
||||
expandAnimation: SharedValue<number>;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
// 翻转按钮的位置动画 - 展开时向右移出
|
||||
const toggleButtonStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, 100], // 向右移出屏幕
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const opacity = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 0.3],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={toggleButtonStyle}>
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>翻转</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
// 动画拍摄按钮组件
|
||||
const AnimatedCaptureButton = ({
|
||||
allRequiredCaptured,
|
||||
expandAnimation,
|
||||
onCapture,
|
||||
onComplete,
|
||||
disabled,
|
||||
loading,
|
||||
}: {
|
||||
allRequiredCaptured: boolean;
|
||||
expandAnimation: SharedValue<number>;
|
||||
onCapture: () => void;
|
||||
onComplete: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}) => {
|
||||
// 单个拍摄按钮的缩放和透明度动画
|
||||
const singleButtonStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 0.3],
|
||||
[1, 0],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
transform: [{
|
||||
scale: interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 0.3],
|
||||
[1, 0.8],
|
||||
Extrapolation.CLAMP
|
||||
)
|
||||
}],
|
||||
}));
|
||||
|
||||
// 左侧按钮的位置和透明度动画
|
||||
const leftButtonStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, -70], // 向左移动更多距离
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const opacity = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const scale = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0.8, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX }, { scale }],
|
||||
};
|
||||
});
|
||||
|
||||
// 右侧按钮的位置和透明度动画
|
||||
const rightButtonStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, 70], // 向右移动更多距离
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const opacity = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
const scale = interpolate(
|
||||
expandAnimation.value,
|
||||
[0.4, 1],
|
||||
[0.8, 1],
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
opacity,
|
||||
transform: [{ translateX }, { scale }],
|
||||
};
|
||||
});
|
||||
|
||||
// 容器整体向右平移的动画
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
const translateX = interpolate(
|
||||
expandAnimation.value,
|
||||
[0, 1],
|
||||
[0, 60], // 整体向右移动更多,与相册按钮保持距离
|
||||
Extrapolation.CLAMP
|
||||
);
|
||||
return {
|
||||
transform: [{ translateX }],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.captureButtonContainer, containerStyle]}>
|
||||
{/* 未展开状态:圆形拍摄按钮 */}
|
||||
{!allRequiredCaptured && (
|
||||
<Animated.View style={[styles.singleCaptureWrapper, singleButtonStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.captureBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<View style={styles.captureOuterRing}>
|
||||
<View style={styles.captureInner} />
|
||||
</View>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
|
||||
<View style={styles.captureOuterRing}>
|
||||
<View style={styles.captureInner} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* 展开状态:两个分离的按钮 */}
|
||||
{allRequiredCaptured && (
|
||||
<>
|
||||
{/* 左侧:拍照按钮 */}
|
||||
<Animated.View style={[styles.splitButtonWrapper, leftButtonStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onCapture}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.splitButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(14, 165, 233, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||||
<Ionicons name="camera" size={20} color="#0ea5e9" />
|
||||
<Text style={styles.splitButtonLabel}>拍照</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* 右侧:完成按钮 */}
|
||||
<Animated.View style={[styles.splitButtonWrapper, rightButtonStyle]}>
|
||||
<TouchableOpacity
|
||||
onPress={onComplete}
|
||||
disabled={disabled || loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.splitButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(16, 185, 129, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#10b981" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#10b981" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
|
||||
<Text style={styles.splitButtonLabel}>完成</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
|
||||
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
|
||||
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
|
||||
<Text style={styles.permissionTitle}>需要相机权限</Text>
|
||||
<Text style={styles.permissionTip}>授权后即可快速拍摄药品包装,自动识别信息</Text>
|
||||
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
|
||||
<Text style={styles.permissionBtnText}>授权访问相机</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 引导说明弹窗 - 移到最外层 */}
|
||||
<MedicationPhotoGuideModal
|
||||
visible={showGuideModal}
|
||||
onClose={() => setShowGuideModal(false)}
|
||||
/>
|
||||
|
||||
<View style={styles.container}>
|
||||
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar
|
||||
title="AI 用药识别"
|
||||
onBack={() => router.back()}
|
||||
transparent
|
||||
right={
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowGuideModal(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityLabel="查看拍摄说明"
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.infoButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
|
||||
<Ionicons name="information-circle-outline" size={24} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
<View style={{ height: insets.top + 40 }} />
|
||||
|
||||
<View style={styles.topMeta}>
|
||||
<View style={styles.metaBadge}>
|
||||
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
|
||||
</View>
|
||||
<Text style={styles.metaTitle}>{currentStep.title}</Text>
|
||||
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraCard}>
|
||||
<View style={[styles.cameraFrame, { height: cameraHeight }]}>
|
||||
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
|
||||
<LinearGradient
|
||||
colors={['transparent', 'rgba(0,0,0,0.08)']}
|
||||
style={styles.cameraOverlay}
|
||||
/>
|
||||
{coverPreview ? (
|
||||
<View style={styles.previewBadge}>
|
||||
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.shotsRow}>
|
||||
{captureSteps.map((step, index) => {
|
||||
const active = step.key === currentStep.key;
|
||||
const shot = shots[step.key];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={step.key}
|
||||
onPress={() => setCurrentStepIndex(index)}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.shotCard, active && styles.shotCardActive]}
|
||||
>
|
||||
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
|
||||
{step.title}
|
||||
{!step.mandatory ? '(可选)' : ''}
|
||||
</Text>
|
||||
{shot ? (
|
||||
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.shotPlaceholder}>
|
||||
<Text style={styles.shotPlaceholderText}>未拍摄</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
|
||||
<View style={styles.bottomActions}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePickFromAlbum}
|
||||
disabled={creatingTask || uploading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.secondaryBtn}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.6)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
|
||||
<Ionicons name="images-outline" size={20} color="#0f172a" />
|
||||
<Text style={styles.secondaryBtnText}>从相册</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<AnimatedCaptureButton
|
||||
allRequiredCaptured={allRequiredCaptured}
|
||||
expandAnimation={expandAnimation}
|
||||
onCapture={handleTakePicture}
|
||||
onComplete={handleStartRecognition}
|
||||
disabled={creatingTask}
|
||||
loading={creatingTask || uploading}
|
||||
/>
|
||||
|
||||
<AnimatedToggleButton
|
||||
expandAnimation={expandAnimation}
|
||||
onPress={handleToggleCamera}
|
||||
disabled={creatingTask}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
topMeta: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 6,
|
||||
},
|
||||
metaBadge: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#e0f2fe',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
metaBadgeText: {
|
||||
color: '#0369a1',
|
||||
fontWeight: '700',
|
||||
fontSize: 12,
|
||||
},
|
||||
metaTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
metaSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
},
|
||||
cameraCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 12,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
cameraFrame: {
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#0b172a',
|
||||
height: 360,
|
||||
},
|
||||
cameraView: {
|
||||
flex: 1,
|
||||
},
|
||||
cameraOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
},
|
||||
previewBadge: {
|
||||
position: 'absolute',
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
width: 90,
|
||||
height: 90,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
shotsRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
shotCard: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#f8fafc',
|
||||
padding: 10,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
shotCardActive: {
|
||||
borderColor: '#38bdf8',
|
||||
backgroundColor: '#ecfeff',
|
||||
},
|
||||
shotLabel: {
|
||||
fontSize: 12,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
},
|
||||
shotLabelActive: {
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
shotThumb: {
|
||||
width: '100%',
|
||||
height: 70,
|
||||
borderRadius: 12,
|
||||
},
|
||||
shotPlaceholder: {
|
||||
height: 70,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#e2e8f0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
shotPlaceholderText: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 12,
|
||||
},
|
||||
bottomBar: {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
bottomActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureButtonContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
height: 64,
|
||||
},
|
||||
singleCaptureWrapper: {
|
||||
position: 'absolute',
|
||||
},
|
||||
captureBtn: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
fallbackCaptureBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
captureOuterRing: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
captureInner: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
},
|
||||
splitButtonWrapper: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
splitButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
width: 110,
|
||||
height: 48,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
fallbackSplitButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
},
|
||||
splitButtonLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#0f172a',
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
},
|
||||
fallbackSecondaryBtn: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(15, 23, 42, 0.1)',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
primaryCta: {
|
||||
marginTop: 6,
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
},
|
||||
primaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
skipBtn: {
|
||||
alignSelf: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
skipText: {
|
||||
color: '#475569',
|
||||
fontSize: 13,
|
||||
},
|
||||
infoButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackInfoButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
permissionCard: {
|
||||
marginHorizontal: 24,
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
permissionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
permissionTip: {
|
||||
fontSize: 14,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
permissionBtn: {
|
||||
marginTop: 6,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
permissionBtnText: {
|
||||
color: '#fff',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
514
app/medications/ai-progress.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||||
import { MedicationRecognitionTask } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [
|
||||
{ key: 'analyzing_product', label: '正在进行产品分析...' },
|
||||
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' },
|
||||
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' },
|
||||
{ key: 'analyzing_effects', label: '正在生成安全建议...' },
|
||||
];
|
||||
|
||||
export default function MedicationAiProgressScreen() {
|
||||
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const navigatingRef = useRef(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 动画值:上下浮动和透明度
|
||||
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0.3)).current;
|
||||
|
||||
const currentStepIndex = useMemo(() => {
|
||||
if (!task) return 0;
|
||||
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status);
|
||||
if (idx >= 0) return idx;
|
||||
if (task.status === 'completed') return STATUS_STEPS.length;
|
||||
return 0;
|
||||
}, [task]);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!taskId || navigatingRef.current) return;
|
||||
try {
|
||||
const data = await getMedicationRecognitionStatus(taskId as string);
|
||||
setTask(data);
|
||||
setError(null);
|
||||
|
||||
// 识别成功,跳转到详情页
|
||||
if (data.status === 'completed' && data.result && !navigatingRef.current) {
|
||||
navigatingRef.current = true;
|
||||
// 清除轮询
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
router.replace({
|
||||
pathname: '/medications/[medicationId]',
|
||||
params: {
|
||||
medicationId: 'ai-draft',
|
||||
aiTaskId: data.taskId,
|
||||
cover: (cover as string) || data.result.photoUrl || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 识别失败,停止轮询并显示错误弹窗
|
||||
if (data.status === 'failed' && !navigatingRef.current) {
|
||||
navigatingRef.current = true;
|
||||
// 清除轮询
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
// 显示错误提示弹窗
|
||||
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄');
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[MEDICATION_AI] status failed', err);
|
||||
setError(err?.message || '查询失败,请稍后再试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重新拍摄
|
||||
const handleRetry = () => {
|
||||
setShowErrorModal(false);
|
||||
router.back();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
pollingTimerRef.current = setInterval(fetchStatus, 2400);
|
||||
return () => {
|
||||
if (pollingTimerRef.current) {
|
||||
clearInterval(pollingTimerRef.current);
|
||||
pollingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// 启动浮动和闪烁动画 - 更快的动画速度
|
||||
useEffect(() => {
|
||||
// 上下浮动动画 - 加快速度
|
||||
const floatAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: -10,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(floatAnim, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// 透明度闪烁动画 - 加快速度,增加对比度
|
||||
const opacityAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0.4,
|
||||
duration: 800,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
floatAnimation.start();
|
||||
opacityAnimation.start();
|
||||
|
||||
return () => {
|
||||
floatAnimation.stop();
|
||||
opacityAnimation.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar title="识别中" onBack={() => router.back()} transparent />
|
||||
<View style={{ height: insets.top }} />
|
||||
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.heroImageWrapper}>
|
||||
{cover ? (
|
||||
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
|
||||
) : (
|
||||
<View style={styles.heroPlaceholder} />
|
||||
)}
|
||||
|
||||
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
|
||||
{task?.status !== 'completed' && task?.status !== 'failed' && (
|
||||
<>
|
||||
{/* 深色半透明蒙版层,让点阵更清晰 */}
|
||||
<View style={styles.overlayMask} />
|
||||
|
||||
{/* 渐变蒙版边框,增加视觉层次 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']}
|
||||
style={styles.gradientBorder}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 点阵网格动画 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dottedGrid,
|
||||
{
|
||||
transform: [{ translateY: floatAnim }],
|
||||
opacity: opacityAnim,
|
||||
}
|
||||
]}
|
||||
>
|
||||
{Array.from({ length: 11 }).map((_, idx) => (
|
||||
<View key={idx} style={styles.dotRow}>
|
||||
{Array.from({ length: 11 }).map((__, jdx) => (
|
||||
<View key={`${idx}-${jdx}`} style={styles.dot} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.progressRow}>
|
||||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepList}>
|
||||
{STATUS_STEPS.map((step, index) => {
|
||||
const active = index === currentStepIndex;
|
||||
const done = index < currentStepIndex;
|
||||
return (
|
||||
<View key={step.key} style={styles.stepRow}>
|
||||
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
|
||||
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
|
||||
{step.label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{task?.status === 'completed' && (
|
||||
<View style={styles.stepRow}>
|
||||
<View style={[styles.bullet, styles.bulletDone]} />
|
||||
<Text style={[styles.stepLabel, styles.stepLabelDone]}>识别完成,正在载入详情...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.loadingBox}>
|
||||
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
|
||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||||
</View>
|
||||
|
||||
{/* 识别提示弹窗 */}
|
||||
<Modal
|
||||
visible={showErrorModal}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={handleRetry}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={handleRetry}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={styles.errorModalContainer}
|
||||
>
|
||||
<View style={styles.errorModalContent}>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.errorModalTitle}>需要重新拍摄</Text>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<View style={styles.errorMessageBox}>
|
||||
<Text style={styles.errorMessageText}>{errorMessage}</Text>
|
||||
</View>
|
||||
|
||||
{/* 重新拍摄按钮 */}
|
||||
<TouchableOpacity
|
||||
onPress={handleRetry}
|
||||
activeOpacity={0.8}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.retryButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(14, 165, 233, 0.9)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryButtonGradient}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||
</LinearGradient>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.retryButton}>
|
||||
<LinearGradient
|
||||
colors={['#0ea5e9', '#06b6d4']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.retryButtonGradient}
|
||||
>
|
||||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
heroCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 24,
|
||||
borderRadius: 24,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
heroImageWrapper: {
|
||||
height: 230,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
// 深色蒙版层,让点阵更清晰可见
|
||||
overlayMask: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.35)',
|
||||
},
|
||||
// 渐变边框效果
|
||||
gradientBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 18,
|
||||
},
|
||||
// 点阵网格容器
|
||||
dottedGrid: {
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
dotRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
// 单个点样式 - 更明亮和更大的发光效果
|
||||
dot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 2.5,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
},
|
||||
progressRow: {
|
||||
height: 8,
|
||||
backgroundColor: '#f1f5f9',
|
||||
borderRadius: 10,
|
||||
marginTop: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#0ea5e9',
|
||||
},
|
||||
progressText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
textAlign: 'right',
|
||||
},
|
||||
stepList: {
|
||||
marginTop: 24,
|
||||
marginHorizontal: 24,
|
||||
gap: 14,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
bullet: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
bulletActive: {
|
||||
backgroundColor: '#0ea5e9',
|
||||
},
|
||||
bulletDone: {
|
||||
backgroundColor: '#22c55e',
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 15,
|
||||
color: '#94a3b8',
|
||||
},
|
||||
stepLabelActive: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '700',
|
||||
},
|
||||
stepLabelDone: {
|
||||
color: '#16a34a',
|
||||
fontWeight: '700',
|
||||
},
|
||||
loadingBox: {
|
||||
marginTop: 30,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: '#ef4444',
|
||||
fontSize: 14,
|
||||
},
|
||||
// Modal 样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
errorModalContainer: {
|
||||
width: SCREEN_WIDTH - 48,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 24,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 8,
|
||||
},
|
||||
errorModalContent: {
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorIconContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
errorIconCircle: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorModalTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorMessageBox: {
|
||||
backgroundColor: '#f0f9ff',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 28,
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||||
},
|
||||
errorMessageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: '#475569',
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
retryButtonGradient: {
|
||||
paddingVertical: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
retryButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { Colors } from '@/constants/Colors';
|
||||
import { TIMES_PER_DAY_OPTIONS } from '@/constants/Medication';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { medicationNotificationService } from '@/services/medicationNotifications';
|
||||
import { updateMedicationAction } from '@/store/medicationsSlice';
|
||||
import type { RepeatPattern } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -211,13 +210,6 @@ export default function EditMedicationFrequencyScreen() {
|
||||
})
|
||||
).unwrap();
|
||||
|
||||
// 重新安排药品通知
|
||||
try {
|
||||
await medicationNotificationService.scheduleMedicationNotifications(updated);
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION] 安排药品通知失败:', error);
|
||||
}
|
||||
|
||||
router.back();
|
||||
} catch (err) {
|
||||
console.error('更新频率失败', err);
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
getMedicationReminderEnabled,
|
||||
getMoodReminderEnabled,
|
||||
getNotificationEnabled,
|
||||
getNutritionReminderEnabled,
|
||||
setMedicationReminderEnabled,
|
||||
setNotificationEnabled
|
||||
setMoodReminderEnabled,
|
||||
setNotificationEnabled,
|
||||
setNutritionReminderEnabled
|
||||
} from '@/utils/userPreferences';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useFocusEffect } from 'expo-router';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Alert, Linking, ScrollView, StatusBar, StyleSheet, Switch, Text, View } from 'react-native';
|
||||
|
||||
export default function NotificationSettingsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const safeAreaTop = useSafeAreaTop(60);
|
||||
const { t } = useI18n();
|
||||
const { requestPermission, sendNotification } = useNotifications();
|
||||
const isLgAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 通知设置状态
|
||||
const [notificationEnabled, setNotificationEnabledState] = useState(false);
|
||||
const [medicationReminderEnabled, setMedicationReminderEnabledState] = useState(false);
|
||||
const [nutritionReminderEnabled, setNutritionReminderEnabledState] = useState(false);
|
||||
const [moodReminderEnabled, setMoodReminderEnabledState] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 加载通知设置
|
||||
const loadNotificationSettings = useCallback(async () => {
|
||||
try {
|
||||
const [notification, medicationReminder] = await Promise.all([
|
||||
const [notification, medicationReminder, nutritionReminder, moodReminder] = await Promise.all([
|
||||
getNotificationEnabled(),
|
||||
getMedicationReminderEnabled(),
|
||||
getNutritionReminderEnabled(),
|
||||
getMoodReminderEnabled(),
|
||||
]);
|
||||
setNotificationEnabledState(notification);
|
||||
setMedicationReminderEnabledState(medicationReminder);
|
||||
setNutritionReminderEnabledState(nutritionReminder);
|
||||
setMoodReminderEnabledState(moodReminder);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notification settings:', error);
|
||||
} finally {
|
||||
@@ -87,9 +96,13 @@ export default function NotificationSettingsScreen() {
|
||||
// 关闭推送,保存用户偏好设置
|
||||
await setNotificationEnabled(false);
|
||||
setNotificationEnabledState(false);
|
||||
// 关闭总开关时,也关闭药品提醒
|
||||
// 关闭总开关时,也关闭所有提醒
|
||||
await setMedicationReminderEnabled(false);
|
||||
setMedicationReminderEnabledState(false);
|
||||
await setNutritionReminderEnabled(false);
|
||||
setNutritionReminderEnabledState(false);
|
||||
await setMoodReminderEnabled(false);
|
||||
setMoodReminderEnabledState(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to disable push notifications:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.saveFailed'));
|
||||
@@ -118,57 +131,83 @@ export default function NotificationSettingsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 返回按钮
|
||||
const BackButton = () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isLgAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.glassButton, styles.fallbackButton]}>
|
||||
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||
// 处理营养通知提醒开关变化
|
||||
const handleNutritionReminderToggle = async (value: boolean) => {
|
||||
try {
|
||||
await setNutritionReminderEnabled(value);
|
||||
setNutritionReminderEnabledState(value);
|
||||
|
||||
if (value) {
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.nutritionReminderEnabled.title'),
|
||||
body: t('notificationSettings.alerts.nutritionReminderEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set nutrition reminder:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.nutritionReminderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理心情通知提醒开关变化
|
||||
const handleMoodReminderToggle = async (value: boolean) => {
|
||||
try {
|
||||
await setMoodReminderEnabled(value);
|
||||
setMoodReminderEnabledState(value);
|
||||
|
||||
if (value) {
|
||||
// 发送测试通知
|
||||
await sendNotification({
|
||||
title: t('notificationSettings.alerts.moodReminderEnabled.title'),
|
||||
body: t('notificationSettings.alerts.moodReminderEnabled.body'),
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set mood reminder:', error);
|
||||
Alert.alert(t('notificationSettings.alerts.error.title'), t('notificationSettings.alerts.error.moodReminderFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染设置项
|
||||
const renderSettingItem = (
|
||||
icon: keyof typeof Ionicons.glyphMap,
|
||||
title: string,
|
||||
description: string,
|
||||
value: boolean,
|
||||
onValueChange: (value: boolean) => void,
|
||||
disabled: boolean = false,
|
||||
showSeparator: boolean = true
|
||||
) => (
|
||||
<View>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.itemInfo}>
|
||||
<View style={[styles.iconContainer, disabled && styles.iconContainerDisabled]}>
|
||||
<Ionicons name={icon} size={24} color={disabled ? '#C7C7CC' : '#9370DB'} />
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.itemTitle, disabled && styles.itemTitleDisabled]}>{title}</Text>
|
||||
<Text style={styles.itemDescription} numberOfLines={2}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
/>
|
||||
</View>
|
||||
{showSeparator && (
|
||||
<View style={styles.separatorContainer}>
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 开关项组件
|
||||
const SwitchItem = ({
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<View style={styles.switchItem}>
|
||||
<View style={styles.switchItemLeft}>
|
||||
<Text style={styles.switchItemTitle}>{title}</Text>
|
||||
<Text style={styles.switchItemDescription}>{description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
disabled={disabled}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -177,10 +216,10 @@ export default function NotificationSettingsScreen() {
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>{t('notificationSettings.loading')}</Text>
|
||||
@@ -193,69 +232,82 @@ export default function NotificationSettingsScreen() {
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
<HeaderBar
|
||||
title={t('notificationSettings.title')}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 20,
|
||||
paddingBottom: insets.bottom + 20,
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingTop: safeAreaTop }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<BackButton />
|
||||
<ThemedText style={styles.title}>{t('notificationSettings.title')}</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 通知设置部分 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.notifications')}</Text>
|
||||
<View style={styles.card}>
|
||||
<SwitchItem
|
||||
title={t('notificationSettings.items.pushNotifications.title')}
|
||||
description={t('notificationSettings.items.pushNotifications.description')}
|
||||
value={notificationEnabled}
|
||||
onValueChange={handleNotificationToggle}
|
||||
/>
|
||||
{/* 顶部说明卡片 */}
|
||||
<View style={styles.headerSection}>
|
||||
<Text style={styles.subtitle}>{t('notificationSettings.sections.description')}</Text>
|
||||
<View style={styles.descriptionCard}>
|
||||
<View style={styles.hintRow}>
|
||||
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
|
||||
<Text style={styles.descriptionText}>
|
||||
{t('notificationSettings.description.text')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 药品提醒部分 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.medicationReminder')}</Text>
|
||||
<View style={styles.card}>
|
||||
<SwitchItem
|
||||
title={t('notificationSettings.items.medicationReminder.title')}
|
||||
description={t('notificationSettings.items.medicationReminder.description')}
|
||||
value={medicationReminderEnabled}
|
||||
onValueChange={handleMedicationReminderToggle}
|
||||
disabled={!notificationEnabled}
|
||||
/>
|
||||
</View>
|
||||
{/* 设置项列表 */}
|
||||
<View style={styles.sectionContainer}>
|
||||
{renderSettingItem(
|
||||
'notifications-outline',
|
||||
t('notificationSettings.items.pushNotifications.title'),
|
||||
t('notificationSettings.items.pushNotifications.description'),
|
||||
notificationEnabled,
|
||||
handleNotificationToggle,
|
||||
false,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'medkit-outline',
|
||||
t('notificationSettings.items.medicationReminder.title'),
|
||||
t('notificationSettings.items.medicationReminder.description'),
|
||||
medicationReminderEnabled,
|
||||
handleMedicationReminderToggle,
|
||||
!notificationEnabled,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'restaurant-outline',
|
||||
t('notificationSettings.items.nutritionReminder.title'),
|
||||
t('notificationSettings.items.nutritionReminder.description'),
|
||||
nutritionReminderEnabled,
|
||||
handleNutritionReminderToggle,
|
||||
!notificationEnabled,
|
||||
true
|
||||
)}
|
||||
|
||||
{renderSettingItem(
|
||||
'happy-outline',
|
||||
t('notificationSettings.items.moodReminder.title'),
|
||||
t('notificationSettings.items.moodReminder.description'),
|
||||
moodReminderEnabled,
|
||||
handleMoodReminderToggle,
|
||||
!notificationEnabled,
|
||||
false
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 说明部分 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t('notificationSettings.sections.description')}</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.description}>
|
||||
{t('notificationSettings.description.text')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -264,37 +316,22 @@ export default function NotificationSettingsScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
height: '60%',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
@@ -304,82 +341,95 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
header: {
|
||||
headerSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6C757D',
|
||||
marginBottom: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
descriptionCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(147, 112, 219, 0.1)',
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
gap: 8,
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
descriptionText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: '#2C3E50',
|
||||
lineHeight: 18,
|
||||
},
|
||||
glassButton: {
|
||||
sectionContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
marginBottom: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
backgroundColor: 'rgba(147, 112, 219, 0.05)',
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
switchItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
iconContainerDisabled: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
switchItemLeft: {
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
switchItemTitle: {
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 4,
|
||||
},
|
||||
switchItemDescription: {
|
||||
fontSize: 14,
|
||||
itemTitleDisabled: {
|
||||
color: '#999',
|
||||
},
|
||||
itemDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6C757D',
|
||||
lineHeight: 20,
|
||||
lineHeight: 16,
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.8 }, { scaleY: 0.8 }],
|
||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: '#6C757D',
|
||||
lineHeight: 22,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
separatorContainer: {
|
||||
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
|
||||
paddingRight: 16,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#F0F0F0',
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
@@ -43,6 +44,8 @@ export default function NutritionRecordsScreen() {
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||
const days = getMonthDaysZh();
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -90,7 +93,8 @@ export default function NutritionRecordsScreen() {
|
||||
// 页面聚焦时自动刷新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('营养记录页面聚焦,刷新数据...');
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
if (viewMode === 'daily') {
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
|
||||
interface UserProfile {
|
||||
@@ -81,7 +83,8 @@ export default function EditProfileScreen() {
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [tempValue, setTempValue] = useState<string>('');
|
||||
|
||||
// 输入框字符串
|
||||
// 键盘高度状态
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
|
||||
// 从本地存储加载(身高/体重等本地字段)
|
||||
const loadLocalProfile = async () => {
|
||||
@@ -128,6 +131,34 @@ export default function EditProfileScreen() {
|
||||
loadLocalProfile();
|
||||
}, []);
|
||||
|
||||
// 键盘事件监听器 - 只在名称和体重输入框显示时监听
|
||||
useEffect(() => {
|
||||
// 只有在编辑名称或体重字段时才需要监听键盘(这两个字段使用 TextInput)
|
||||
const needsKeyboardHandling = editingField === 'name' || editingField === 'weight';
|
||||
|
||||
if (!needsKeyboardHandling) {
|
||||
setKeyboardHeight(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const handleShow = (event: any) => {
|
||||
const height = event?.endCoordinates?.height ?? 0;
|
||||
setKeyboardHeight(height);
|
||||
};
|
||||
const handleHide = () => setKeyboardHeight(0);
|
||||
|
||||
const showSub = Keyboard.addListener(showEvent, handleShow);
|
||||
const hideSub = Keyboard.addListener(hideEvent, handleHide);
|
||||
|
||||
return () => {
|
||||
showSub.remove();
|
||||
hideSub.remove();
|
||||
};
|
||||
}, [editingField]);
|
||||
|
||||
// 获取最大心率数据
|
||||
useEffect(() => {
|
||||
const loadMaximumHeartRate = async () => {
|
||||
@@ -439,6 +470,7 @@ export default function EditProfileScreen() {
|
||||
field={editingField}
|
||||
value={tempValue}
|
||||
profile={profile}
|
||||
keyboardHeight={keyboardHeight}
|
||||
onClose={() => {
|
||||
setEditingField(null);
|
||||
setTempValue('');
|
||||
@@ -557,11 +589,12 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled
|
||||
);
|
||||
}
|
||||
|
||||
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor, t }: {
|
||||
function EditModal({ visible, field, value, profile, keyboardHeight, onClose, onSave, colors, textColor, placeholderColor, t }: {
|
||||
visible: boolean;
|
||||
field: string | null;
|
||||
value: string;
|
||||
profile: UserProfile;
|
||||
keyboardHeight: number;
|
||||
onClose: () => void;
|
||||
onSave: (field: string, value: string) => void;
|
||||
colors: any;
|
||||
@@ -569,6 +602,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
placeholderColor: string;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
|
||||
const [selectedActivity, setSelectedActivity] = useState(profile.activityLevel || 1);
|
||||
@@ -685,7 +719,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.modalBackdrop} onPress={onClose} />
|
||||
<View style={styles.editModalSheet}>
|
||||
<View style={[
|
||||
styles.editModalSheet,
|
||||
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 }
|
||||
]}>
|
||||
<View style={styles.modalHandle} />
|
||||
{renderContent()}
|
||||
<View style={styles.modalButtons}>
|
||||
|
||||
266
app/settings/tab-bar-config.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
resetToDefault,
|
||||
selectTabBarConfigs,
|
||||
toggleTabEnabled,
|
||||
type TabConfig,
|
||||
} from '@/store/tabBarConfigSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { palette } from '@/constants/Colors';
|
||||
|
||||
export default function TabBarConfigScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const safeAreaTop = useSafeAreaTop(60);
|
||||
const configs = useAppSelector(selectTabBarConfigs);
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 处理开关切换
|
||||
const handleToggle = useCallback(
|
||||
(tabId: string) => {
|
||||
dispatch(toggleTabEnabled(tabId));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// 恢复默认设置
|
||||
const handleReset = useCallback(() => {
|
||||
Alert.alert(
|
||||
t('personal.tabBarConfig.resetConfirm.title'),
|
||||
t('personal.tabBarConfig.resetConfirm.message'),
|
||||
[
|
||||
{
|
||||
text: t('personal.tabBarConfig.resetConfirm.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: t('personal.tabBarConfig.resetConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
dispatch(resetToDefault());
|
||||
Alert.alert('', t('personal.tabBarConfig.resetSuccess'));
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [dispatch, t]);
|
||||
|
||||
// 渲染单个 Tab 行
|
||||
const renderTabRow = useCallback(
|
||||
(item: TabConfig, index: number, total: number) => {
|
||||
return (
|
||||
<View key={item.id}>
|
||||
<View style={styles.tabItem}>
|
||||
{/* Tab 图标和名称 */}
|
||||
<View style={styles.tabInfo}>
|
||||
<View style={styles.iconContainer}>
|
||||
<IconSymbol name={item.icon as any} size={24} color="#9370DB" />
|
||||
</View>
|
||||
<View style={styles.tabTextContainer}>
|
||||
<Text style={styles.tabTitle}>{t(item.titleKey)}</Text>
|
||||
{!item.canBeDisabled && (
|
||||
<Text style={styles.tabSubtitle}>
|
||||
{t('personal.tabBarConfig.cannotDisable')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开关 */}
|
||||
<Switch
|
||||
value={item.enabled}
|
||||
onValueChange={() => handleToggle(item.id)}
|
||||
disabled={!item.canBeDisabled}
|
||||
trackColor={{ false: '#E5E5E5', true: '#9370DB' }}
|
||||
thumbColor="#FFFFFF"
|
||||
style={styles.switch}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 分割线 - 最后一项不显示 */}
|
||||
{index < total - 1 && (
|
||||
<View style={styles.separatorContainer}>
|
||||
<View style={styles.separator} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[handleToggle, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[palette.purple[100], '#F5F5F5']}
|
||||
start={{ x: 1, y: 0 }}
|
||||
end={{ x: 0.3, y: 0.4 }}
|
||||
style={styles.gradientBackground}
|
||||
/>
|
||||
|
||||
{/* 顶部导航栏 */}
|
||||
<HeaderBar
|
||||
title={t('personal.tabBarConfig.title')}
|
||||
onBack={() => router.back()}
|
||||
right={
|
||||
<TouchableOpacity onPress={handleReset} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
|
||||
<Text style={styles.headerRightButton}>
|
||||
{t('personal.tabBarConfig.resetButton')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingTop: safeAreaTop }]} // 增加顶部间距,因为 HeaderBar 现在是 absolute 的
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 说明区域 */}
|
||||
<View style={styles.headerSection}>
|
||||
<Text style={styles.subtitle}>{t('personal.tabBarConfig.subtitle')}</Text>
|
||||
<View style={styles.descriptionCard}>
|
||||
<View style={styles.hintRow}>
|
||||
<Ionicons name="information-circle-outline" size={20} color="#9370DB" />
|
||||
<Text style={styles.descriptionText}>
|
||||
{t('personal.tabBarConfig.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Tab 列表 - 聚合在一个卡片中 */}
|
||||
<View style={styles.sectionContainer}>
|
||||
{configs.map((item, index) => renderTabRow(item, index, configs.length))}
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '60%', // 渐变覆盖上半部分即可
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
headerSection: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6C757D',
|
||||
marginBottom: 12,
|
||||
},
|
||||
descriptionCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(147, 112, 219, 0.1)',
|
||||
},
|
||||
hintRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
descriptionText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: '#2C3E50',
|
||||
lineHeight: 18,
|
||||
},
|
||||
sectionContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
marginBottom: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
tabItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
separatorContainer: {
|
||||
paddingLeft: 68, // 40(icon) + 12(gap) + 16(padding)
|
||||
paddingRight: 16,
|
||||
},
|
||||
separator: {
|
||||
height: 1,
|
||||
backgroundColor: '#F0F0F0',
|
||||
},
|
||||
tabInfo: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
tabTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 2,
|
||||
},
|
||||
tabSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#9370DB',
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||
},
|
||||
headerRightButton: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#9370DB', // 使用主色调
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { deleteWeightRecord, fetchWeightHistory, updateUserProfile, updateWeightRecord, WeightHistoryItem } from '@/store/userSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -107,6 +108,13 @@ export default function WeightRecordsPage() {
|
||||
if (pickerType === 'current') {
|
||||
// Update current weight in profile and add weight record
|
||||
await dispatch(updateUserProfile({ weight: weight }) as any);
|
||||
|
||||
// 记录体重后尝试请求应用评分(延迟1秒,避免阻塞主流程)
|
||||
setTimeout(() => {
|
||||
appStoreReviewService.requestReview().catch((error) => {
|
||||
console.error('应用评分请求失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
} else if (pickerType === 'initial') {
|
||||
// Update initial weight in profile
|
||||
console.log('更新初始体重');
|
||||
|
||||
@@ -29,6 +29,7 @@ const WORKOUT_TYPES = [
|
||||
{ key: 'walking', label: '步行' },
|
||||
{ key: 'other', label: '其他运动' },
|
||||
];
|
||||
const WORKOUT_TYPE_KEYS = WORKOUT_TYPES.map(type => type.key);
|
||||
|
||||
export default function WorkoutNotificationSettingsScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
@@ -80,16 +81,18 @@ export default function WorkoutNotificationSettingsScreen() {
|
||||
};
|
||||
|
||||
const handleWorkoutTypeToggle = (workoutType: string) => {
|
||||
const currentTypes = preferences.enabledWorkoutTypes;
|
||||
let newTypes: string[];
|
||||
const currentTypes = preferences.enabledWorkoutTypes.length === 0
|
||||
? [...WORKOUT_TYPE_KEYS] // 空数组表示全部启用,先展开成完整列表,避免影响其他开关的当前状态
|
||||
: [...preferences.enabledWorkoutTypes];
|
||||
|
||||
if (currentTypes.includes(workoutType)) {
|
||||
newTypes = currentTypes.filter(type => type !== workoutType);
|
||||
} else {
|
||||
newTypes = [...currentTypes, workoutType];
|
||||
}
|
||||
const nextTypes = currentTypes.includes(workoutType)
|
||||
? currentTypes.filter(type => type !== workoutType)
|
||||
: [...currentTypes, workoutType];
|
||||
|
||||
savePreferences({ enabledWorkoutTypes: newTypes });
|
||||
// 如果全部类型都开启,回退为空数组表示“全部启用”,以保持原有存储约定
|
||||
const normalizedTypes = nextTypes.length === WORKOUT_TYPE_KEYS.length ? [] : nextTypes;
|
||||
|
||||
savePreferences({ enabledWorkoutTypes: normalizedTypes });
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
|
||||
BIN
assets/fonts/ali-bold.ttf
Normal file
BIN
assets/fonts/ali-regular.ttf
Normal file
BIN
assets/logo.png
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 672 KiB |
1
assets/lottie/loading-blue.json
Normal file
BIN
assets/machine.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -2,6 +2,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
@@ -12,6 +13,7 @@ const ActivityHeatMap = () => {
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const activityData = useAppSelector(stat => stat.user.activityHistory);
|
||||
|
||||
@@ -103,8 +105,20 @@ const ActivityHeatMap = () => {
|
||||
|
||||
// 获取月份标签(简化的月份标签系统)
|
||||
const getMonthLabels = useMemo(() => {
|
||||
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const monthNames = [
|
||||
t('statistics.activityHeatMap.months.1'),
|
||||
t('statistics.activityHeatMap.months.2'),
|
||||
t('statistics.activityHeatMap.months.3'),
|
||||
t('statistics.activityHeatMap.months.4'),
|
||||
t('statistics.activityHeatMap.months.5'),
|
||||
t('statistics.activityHeatMap.months.6'),
|
||||
t('statistics.activityHeatMap.months.7'),
|
||||
t('statistics.activityHeatMap.months.8'),
|
||||
t('statistics.activityHeatMap.months.9'),
|
||||
t('statistics.activityHeatMap.months.10'),
|
||||
t('statistics.activityHeatMap.months.11'),
|
||||
t('statistics.activityHeatMap.months.12'),
|
||||
];
|
||||
|
||||
// 简单策略:均匀分布4-5个月份标签
|
||||
const totalWeeks = weeksToShow;
|
||||
@@ -130,7 +144,7 @@ const ActivityHeatMap = () => {
|
||||
});
|
||||
|
||||
return labelPositions;
|
||||
}, [organizeDataByWeeks, weeksToShow]);
|
||||
}, [organizeDataByWeeks, weeksToShow, t]);
|
||||
|
||||
// 计算活动统计
|
||||
const activityStats = useMemo(() => {
|
||||
@@ -156,14 +170,14 @@ const ActivityHeatMap = () => {
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
|
||||
最近6个月活跃 {activityStats.activeDays} 天
|
||||
{t('statistics.activityHeatMap.subtitle', { days: activityStats.activeDays })}
|
||||
</Text>
|
||||
<View style={styles.rightSection}>
|
||||
<View style={[styles.statsBadge, {
|
||||
backgroundColor: 'rgba(122, 90, 248, 0.1)'
|
||||
}]}>
|
||||
<Text style={[styles.statsText, { color: colors.primary }]}>
|
||||
{activityStats.activeRate}%
|
||||
{t('statistics.activityHeatMap.activeRate', { rate: activityStats.activeRate })}
|
||||
</Text>
|
||||
</View>
|
||||
<Popover
|
||||
@@ -184,23 +198,23 @@ const ActivityHeatMap = () => {
|
||||
>
|
||||
<View style={[styles.popoverContent, { backgroundColor: colors.card }]}>
|
||||
<Text style={[styles.popoverTitle, { color: colors.text }]}>
|
||||
能量值的积攒后续可以用来兑换 AI 相关权益
|
||||
{t('statistics.activityHeatMap.popover.title')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverSubtitle, { color: colors.text }]}>
|
||||
获取说明
|
||||
{t('statistics.activityHeatMap.popover.subtitle')}
|
||||
</Text>
|
||||
<View style={styles.popoverList}>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
1. 每日登录获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.login')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
2. 每日记录心情获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.mood')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
3. 记饮食获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.diet')}
|
||||
</Text>
|
||||
<Text style={[styles.popoverItem, { color: colors.textMuted }]}>
|
||||
4. 完成一次目标获得能量值+1
|
||||
{t('statistics.activityHeatMap.popover.rules.goal')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -263,7 +277,9 @@ const ActivityHeatMap = () => {
|
||||
|
||||
{/* 图例 */}
|
||||
<View style={styles.legend}>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>少</Text>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>
|
||||
{t('statistics.activityHeatMap.legend.less')}
|
||||
</Text>
|
||||
<View style={styles.legendColors}>
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<View
|
||||
@@ -278,7 +294,9 @@ const ActivityHeatMap = () => {
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>多</Text>
|
||||
<Text style={[styles.legendText, { color: colors.textMuted }]}>
|
||||
{t('statistics.activityHeatMap.legend.more')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -100,6 +100,8 @@ export function NutritionRadarCard({
|
||||
const [currentMealType] = useState<'breakfast' | 'lunch' | 'dinner' | 'snack'>('breakfast');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { isLoggedIn } = useAuthGuard()
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -121,10 +123,11 @@ export function NutritionRadarCard({
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([
|
||||
dispatch(fetchDailyNutritionData(targetDate)).unwrap(),
|
||||
dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap(),
|
||||
]);
|
||||
|
||||
if (isLoggedIn) {
|
||||
await dispatch(fetchDailyNutritionData(targetDate)).unwrap()
|
||||
}
|
||||
await dispatch(fetchDailyBasalMetabolism(targetDate)).unwrap()
|
||||
} catch (error) {
|
||||
console.error('NutritionRadarCard: Failed to get nutrition card data:', error);
|
||||
} finally {
|
||||
@@ -133,7 +136,7 @@ export function NutritionRadarCard({
|
||||
};
|
||||
|
||||
loadNutritionCardData();
|
||||
}, [selectedDate, dispatch]);
|
||||
}, [selectedDate, dispatch, isLoggedIn]);
|
||||
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -139,6 +140,9 @@ const WaterIntakeCard: React.FC<WaterIntakeCardProps> = ({
|
||||
// 如果有选中日期,则为该日期添加记录;否则为今天添加记录
|
||||
const recordedAt = dayjs().toISOString()
|
||||
await addWaterRecord(waterAmount, recordedAt);
|
||||
|
||||
// 记录饮水后尝试请求应用评分
|
||||
await appStoreReviewService.requestReview();
|
||||
};
|
||||
|
||||
// 处理卡片点击 - 跳转到饮水详情页面
|
||||
|
||||
@@ -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,
|
||||
|
||||
409
components/medication/MedicationAddOptionsSheet.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Animated, Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onManualAdd: () => void;
|
||||
onAiRecognize: () => void;
|
||||
};
|
||||
|
||||
export function MedicationAddOptionsSheet({ visible, onClose, onManualAdd, onAiRecognize }: Props) {
|
||||
const translateY = useRef(new Animated.Value(300)).current;
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// 打开时:先显示 Modal,然后执行动画
|
||||
setModalVisible(true);
|
||||
Animated.parallel([
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
tension: 65,
|
||||
friction: 11,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else if (modalVisible) {
|
||||
// 关闭时:先执行动画,动画完成后隐藏 Modal
|
||||
Animated.parallel([
|
||||
Animated.timing(translateY, {
|
||||
toValue: 300,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
setModalVisible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [visible, modalVisible, opacity, translateY]);
|
||||
|
||||
const handleClose = () => {
|
||||
// 触发关闭动画
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={modalVisible} transparent animationType="none" onRequestClose={handleClose}>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Animated.View style={[styles.backdrop, { opacity }]} />
|
||||
</Pressable>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.title}>添加药物</Text>
|
||||
<Text style={styles.subtitle}>选择录入方式</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleClose} style={styles.closeButton} activeOpacity={0.7}>
|
||||
<Ionicons name="close" size={24} color="#64748b" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* AI 智能识别 - 主推荐 */}
|
||||
<TouchableOpacity activeOpacity={0.95} onPress={onAiRecognize}>
|
||||
<LinearGradient
|
||||
colors={['#0ea5e9', '#0284c7']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.aiCard}
|
||||
>
|
||||
{/* 推荐标签 */}
|
||||
<View style={styles.recommendBadge}>
|
||||
<Ionicons name="sparkles" size={14} color="#fbbf24" />
|
||||
<Text style={styles.recommendText}>推荐使用</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.aiContent}>
|
||||
<View style={styles.aiLeft}>
|
||||
<View style={styles.aiIconWrapper}>
|
||||
<Ionicons name="camera" size={32} color="#fff" />
|
||||
</View>
|
||||
<View style={styles.aiTexts}>
|
||||
<Text style={styles.aiTitle}>AI 智能识别</Text>
|
||||
<Text style={styles.aiDescription}>
|
||||
拍照识别药品信息{'\n'}自动生成提醒计划
|
||||
</Text>
|
||||
<View style={styles.aiFeatures}>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="flash" size={14} color="#fff" />
|
||||
<Text style={styles.featureText}>快速识别</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<Ionicons name="checkmark-circle" size={14} color="#fff" />
|
||||
<Text style={styles.featureText}>智能填充</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Image
|
||||
source={require('@/assets/images/medicine/image-medicine.png')}
|
||||
style={styles.aiImage}
|
||||
contentFit="contain"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* AI 说明 */}
|
||||
<View style={styles.aiFooter}>
|
||||
<Ionicons name="information-circle-outline" size={14} color="rgba(255,255,255,0.8)" />
|
||||
<Text style={styles.aiFooterText}>需会员或 AI 次数 · 拍摄时确保光线充足</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>或</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
{/* 手动录入 - 次要选项 */}
|
||||
<TouchableOpacity activeOpacity={0.9} onPress={onManualAdd}>
|
||||
<View style={styles.manualCard}>
|
||||
<View style={styles.manualLeft}>
|
||||
<View style={styles.manualIconWrapper}>
|
||||
<Ionicons name="create-outline" size={24} color="#6366f1" />
|
||||
</View>
|
||||
<View style={styles.manualTexts}>
|
||||
<Text style={styles.manualTitle}>手动录入</Text>
|
||||
<Text style={styles.manualDescription}>
|
||||
逐项填写药品信息和服用计划
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.manualRight}>
|
||||
<View style={styles.manualBadge}>
|
||||
<Text style={styles.manualBadgeText}>免费</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#94a3b8" />
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 底部安全距离 */}
|
||||
<View style={styles.safeArea} />
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
},
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
paddingTop: 24,
|
||||
paddingHorizontal: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: { width: 0, height: -8 },
|
||||
elevation: 12,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
color: '#64748b',
|
||||
fontWeight: '500',
|
||||
},
|
||||
closeButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#f1f5f9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 12,
|
||||
},
|
||||
|
||||
// AI 卡片 - 主推荐
|
||||
aiCard: {
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#0ea5e9',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 8,
|
||||
},
|
||||
recommendBadge: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
recommendText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
aiContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
aiLeft: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
},
|
||||
aiIconWrapper: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
aiTexts: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
aiTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
aiDescription: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
lineHeight: 20,
|
||||
},
|
||||
aiFeatures: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
featureText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
aiImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
marginLeft: 12,
|
||||
},
|
||||
aiFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
aiFooterText: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 分隔线
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 13,
|
||||
color: '#94a3b8',
|
||||
fontWeight: '600',
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
|
||||
// 手动录入卡片 - 次要选项
|
||||
manualCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
manualLeft: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
manualIconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#eef2ff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
manualTexts: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
manualTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
},
|
||||
manualDescription: {
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
lineHeight: 18,
|
||||
},
|
||||
manualRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginLeft: 12,
|
||||
},
|
||||
manualBadge: {
|
||||
backgroundColor: '#dcfce7',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
manualBadgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#16a34a',
|
||||
},
|
||||
|
||||
// 底部安全距离
|
||||
safeArea: {
|
||||
height: 32,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { takeMedicationAction } from '@/store/medicationsSlice';
|
||||
import { skipMedicationAction, takeMedicationAction } from '@/store/medicationsSlice';
|
||||
import type { MedicationDisplayItem } from '@/types/medication';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
@@ -100,6 +100,64 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理跳过操作
|
||||
*/
|
||||
const handleSkipMedication = async () => {
|
||||
// 检查 recordId 是否存在
|
||||
if (!medication.recordId || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示二次确认弹窗
|
||||
Alert.alert(
|
||||
t('medications.card.skipAlert.title'),
|
||||
t('medications.card.skipAlert.message'),
|
||||
[
|
||||
{
|
||||
text: t('medications.card.skipAlert.cancel'),
|
||||
style: 'cancel',
|
||||
onPress: () => {
|
||||
console.log('用户取消跳过');
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('medications.card.skipAlert.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
executeSkipMedication(medication.recordId!);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行跳过操作
|
||||
*/
|
||||
const executeSkipMedication = async (recordId: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 调用 Redux action 标记为已跳过
|
||||
await dispatch(skipMedicationAction({
|
||||
recordId: recordId,
|
||||
})).unwrap();
|
||||
|
||||
// 可选:显示成功提示
|
||||
// Alert.alert('跳过成功', '已跳过本次用药');
|
||||
} catch (error) {
|
||||
console.error('[MEDICATION_CARD] 跳过操作失败', error);
|
||||
Alert.alert(
|
||||
t('medications.card.skipError.title'),
|
||||
error instanceof Error ? error.message : t('medications.card.skipError.message'),
|
||||
[{ text: t('medications.card.skipError.confirm') }]
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatusBadge = () => {
|
||||
if (medication.status === 'missed') {
|
||||
return (
|
||||
@@ -122,12 +180,12 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
const hours = Math.floor(timeDiffMinutes / 60);
|
||||
const minutes = timeDiffMinutes % 60;
|
||||
const formatted =
|
||||
hours > 0 ? `${hours}小时${minutes > 0 ? `${minutes}分钟` : ''}` : `${minutes}分钟`;
|
||||
hours > 0 ? `${hours}:${minutes > 0 ? `${minutes}` : ''}` : `${minutes}`;
|
||||
|
||||
return (
|
||||
<View style={[styles.statusChip, styles.statusChipUpcoming]}>
|
||||
<Ionicons name="time-outline" size={14} color="#fff" />
|
||||
<ThemedText style={styles.statusChipText}>{t('medications.card.status.remaining', { time: formatted })}</ThemedText>
|
||||
<Ionicons name="time-outline" size={10} color="#fff" />
|
||||
<ThemedText style={styles.statusChipText}>{formatted}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +194,7 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
};
|
||||
|
||||
const renderAction = () => {
|
||||
// 已服用状态
|
||||
if (medication.status === 'taken') {
|
||||
return (
|
||||
<View style={[styles.actionButton, styles.actionButtonTaken]}>
|
||||
@@ -145,32 +204,73 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
);
|
||||
}
|
||||
|
||||
// 只要没有服药,都可以显示立即服用
|
||||
// 已跳过状态
|
||||
if (medication.status === 'skipped') {
|
||||
return (
|
||||
<View style={[styles.actionButton, styles.actionButtonSkipped]}>
|
||||
<Ionicons name="close-circle" size={18} color="#fff" />
|
||||
<ThemedText style={styles.actionButtonText}>{t('medications.card.action.skipped')}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 待服用或已错过状态,显示操作按钮
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleTakeMedication}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(19, 99, 255, 0.3)"
|
||||
isInteractive={!isSubmitting}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
|
||||
</ThemedText>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View style={styles.actionButtonsRow}>
|
||||
{/* 跳过按钮 */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleSkipMedication}
|
||||
disabled={isSubmitting}
|
||||
style={styles.skipButtonWrapper}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={[styles.actionButton, styles.actionButtonSkip]}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(156, 163, 175, 0.2)"
|
||||
isInteractive={!isSubmitting}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonTextSkip}>
|
||||
{t('medications.card.action.skip')}
|
||||
</ThemedText>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.actionButton, styles.actionButtonSkip, styles.fallbackActionButtonSkip]}>
|
||||
<ThemedText style={styles.actionButtonTextSkip}>
|
||||
{t('medications.card.action.skip')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 立即服用按钮 */}
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleTakeMedication}
|
||||
disabled={isSubmitting}
|
||||
style={styles.takeButtonWrapper}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={[styles.actionButton, styles.actionButtonUpcoming]}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(19, 99, 255, 0.3)"
|
||||
isInteractive={!isSubmitting}
|
||||
>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
|
||||
</ThemedText>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.actionButton, styles.actionButtonUpcoming, styles.fallbackActionButton]}>
|
||||
<ThemedText style={styles.actionButtonText}>
|
||||
{isSubmitting ? t('medications.card.action.submitting') : t('medications.card.action.takeNow')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -227,11 +327,11 @@ export function MedicationCard({ medication, colors, selectedDate, onOpenDetails
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
borderRadius: 24,
|
||||
position: 'relative',
|
||||
},
|
||||
cardSurface: {
|
||||
borderRadius: 18,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cardBody: {
|
||||
@@ -254,7 +354,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 18,
|
||||
borderRadius: 24,
|
||||
},
|
||||
thumbnailImage: {
|
||||
width: '70%',
|
||||
@@ -265,8 +365,9 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
maxWidth: '70%',
|
||||
},
|
||||
cardDosage: {
|
||||
fontSize: 12,
|
||||
@@ -286,6 +387,16 @@ const styles = StyleSheet.create({
|
||||
actionContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
actionButtonsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
skipButtonWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
takeButtonWrapper: {
|
||||
flex: 2,
|
||||
},
|
||||
actionButton: {
|
||||
alignSelf: 'stretch',
|
||||
flexDirection: 'row',
|
||||
@@ -302,6 +413,12 @@ const styles = StyleSheet.create({
|
||||
actionButtonTaken: {
|
||||
backgroundColor: '#1FBF4B',
|
||||
},
|
||||
actionButtonSkipped: {
|
||||
backgroundColor: '#9CA3AF',
|
||||
},
|
||||
actionButtonSkip: {
|
||||
backgroundColor: '#E5E7EB',
|
||||
},
|
||||
actionButtonMissed: {
|
||||
backgroundColor: '#9CA3AF',
|
||||
},
|
||||
@@ -310,6 +427,11 @@ const styles = StyleSheet.create({
|
||||
borderColor: 'rgba(19, 99, 255, 0.3)',
|
||||
backgroundColor: 'rgba(19, 99, 255, 0.9)',
|
||||
},
|
||||
fallbackActionButtonSkip: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(156, 163, 175, 0.2)',
|
||||
backgroundColor: 'rgba(229, 231, 235, 0.9)',
|
||||
},
|
||||
fallbackActionButtonMissed: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(156, 163, 175, 0.3)',
|
||||
@@ -320,6 +442,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
actionButtonTextSkip: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
},
|
||||
actionButtonTextMissed: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
@@ -340,6 +467,7 @@ const styles = StyleSheet.create({
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
backgroundColor: '#1363FF',
|
||||
zIndex: 1
|
||||
},
|
||||
statusChipUpcoming: {
|
||||
backgroundColor: '#1363FF',
|
||||
@@ -348,7 +476,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#FF3B30',
|
||||
},
|
||||
statusChipText: {
|
||||
fontSize: 10,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
|
||||
272
components/medication/TakenMedicationsStack.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import type { MedicationDisplayItem } from '@/types/medication';
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { MedicationCard } from './MedicationCard';
|
||||
|
||||
type Props = {
|
||||
medications: MedicationDisplayItem[];
|
||||
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||||
selectedDate: any;
|
||||
onOpenDetails: (medication: MedicationDisplayItem) => void;
|
||||
onCelebrate?: () => void;
|
||||
};
|
||||
|
||||
const STACK_OFFSET = 12;
|
||||
const STACK_SCALE_STEP = 0.04;
|
||||
const MAX_STACK_VISIBLE = 3;
|
||||
|
||||
export function TakenMedicationsStack({
|
||||
medications,
|
||||
colors,
|
||||
selectedDate,
|
||||
onOpenDetails,
|
||||
onCelebrate,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withSpring(isExpanded ? 1 : 0, {
|
||||
damping: 20,
|
||||
stiffness: 200, // Faster spring
|
||||
mass: 0.8,
|
||||
});
|
||||
}, [isExpanded, progress]);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
// Header arrow rotation style
|
||||
const arrowStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
rotate: `${interpolate(progress.value, [0, 1], [0, 180])}deg`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
if (medications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Stack/List Container */}
|
||||
<View style={[styles.stackContainer, { minHeight: isExpanded ? undefined : 130 }]}>
|
||||
{medications.map((item, index) => (
|
||||
<CardItem
|
||||
key={item.id || index}
|
||||
item={item}
|
||||
index={index}
|
||||
total={medications.length}
|
||||
progress={progress}
|
||||
isExpanded={isExpanded}
|
||||
colors={colors}
|
||||
selectedDate={selectedDate}
|
||||
onOpenDetails={onOpenDetails}
|
||||
onCelebrate={onCelebrate}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CardItem = ({
|
||||
item,
|
||||
index,
|
||||
total,
|
||||
progress,
|
||||
isExpanded,
|
||||
colors,
|
||||
selectedDate,
|
||||
onOpenDetails,
|
||||
onCelebrate,
|
||||
onToggle,
|
||||
}: {
|
||||
item: MedicationDisplayItem;
|
||||
index: number;
|
||||
total: number;
|
||||
progress: SharedValue<number>;
|
||||
isExpanded: boolean;
|
||||
colors: (typeof import('@/constants/Colors').Colors)[keyof typeof import('@/constants/Colors').Colors];
|
||||
selectedDate: any;
|
||||
onOpenDetails: (medication: MedicationDisplayItem) => void;
|
||||
onCelebrate?: () => void;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
// Only render top 3 cards when collapsed to save performance/visuals
|
||||
// But we need to render all when expanding.
|
||||
// We'll hide index >= MAX_STACK_VISIBLE when collapsed via opacity/zIndex.
|
||||
|
||||
const style = useAnimatedStyle(() => {
|
||||
// Stack state (progress = 0)
|
||||
const stackTranslateY = index * STACK_OFFSET;
|
||||
const stackScale = 1 - index * STACK_SCALE_STEP;
|
||||
const stackOpacity = index < MAX_STACK_VISIBLE ? 1 - index * 0.15 : 0;
|
||||
const stackZIndex = total - index;
|
||||
|
||||
// List state (progress = 1)
|
||||
// In list state, we rely on layout (relative positioning).
|
||||
// However, to animate smoothly from absolute (stack) to relative (list),
|
||||
// we need a strategy.
|
||||
// Strategy: Always Absolute? No, height is dynamic.
|
||||
// Strategy: Use negative margins for stack?
|
||||
|
||||
// Let's try:
|
||||
// Collapsed: marginTop = -(height - offset).
|
||||
// Expanded: marginTop = 16 (gap).
|
||||
// But we don't know height.
|
||||
|
||||
// Alternative:
|
||||
// Use 'top' offset relative to the first card?
|
||||
// This is hard without measuring.
|
||||
|
||||
// Let's go with the "Transform" approach assuming standard card height for the stack effect,
|
||||
// but switching to relative layout when expanded.
|
||||
// Wait, switching 'position' prop is not animatable by useAnimatedStyle directly (requires Layout Animation).
|
||||
|
||||
// Let's keep it simple:
|
||||
// When collapsed (progress 0):
|
||||
// Items > 0 are absolutely positioned relative to the container (which wraps them all).
|
||||
// Item 0 is relative.
|
||||
// When expanded (progress 1):
|
||||
// All items are relative.
|
||||
|
||||
// To smooth this, we can use interpolate for translateY.
|
||||
|
||||
return {
|
||||
zIndex: stackZIndex,
|
||||
opacity: interpolate(progress.value, [0, 1], [stackOpacity, 1]),
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(progress.value, [0, 1], [stackScale, 1]),
|
||||
},
|
||||
{
|
||||
translateY: interpolate(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[stackTranslateY, 0] // In stack, they go down. In list, translation is 0 (relative flow handles pos).
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// Logic for positioning:
|
||||
// We'll use a container View for each card.
|
||||
// When collapsed, the container height for index > 0 should be 0?
|
||||
// That would pull them up.
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
// We can animate the height of the wrapper view.
|
||||
// But we don't know the content height.
|
||||
// Assuming ~140px for card.
|
||||
const approxHeight = 140;
|
||||
|
||||
if (index === 0) return {}; // First card always takes space
|
||||
|
||||
// For others:
|
||||
// Collapsed: height is 0 (so they stack on top of first one, roughly)
|
||||
// Expanded: height is 'auto' (we can't animate to auto easily in RN without LayoutAnimation)
|
||||
|
||||
return {
|
||||
marginTop: interpolate(progress.value, [0, 1], [-approxHeight + STACK_OFFSET, 16], Extrapolation.CLAMP),
|
||||
};
|
||||
});
|
||||
|
||||
// Using Layout Animation for the actual position change support
|
||||
// requires the parent to handle it.
|
||||
|
||||
// Simpler Visual Hack:
|
||||
// When collapsed, we just set marginTop to a negative value that overlaps them.
|
||||
// Since MedicationCard is roughly constant height, we can tune this.
|
||||
// MedicationCard height is roughly 130-150.
|
||||
// Let's guess -130 + 12.
|
||||
|
||||
const cardContainerStyle = useAnimatedStyle(() => {
|
||||
// We assume a fixed height for the negative margin calculation logic.
|
||||
// A better way is needed if heights vary wildly.
|
||||
// But for now, let's use a safe estimated overlap.
|
||||
const cardHeight = 140;
|
||||
const collapsedMarginTop = index === 0 ? 0 : -(cardHeight - STACK_OFFSET);
|
||||
const expandedMarginTop = index === 0 ? 0 : 16;
|
||||
|
||||
return {
|
||||
marginTop: interpolate(progress.value, [0, 1], [collapsedMarginTop, expandedMarginTop]),
|
||||
zIndex: total - index,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[cardContainerStyle, style]}>
|
||||
{/* When collapsed, clicking any card should expand. When expanded, open details. */}
|
||||
{/* We can intercept touches if !isExpanded */}
|
||||
<View style={{ position: 'relative' }}>
|
||||
{/* Overlay to intercept clicks when collapsed */}
|
||||
{!isExpanded && (
|
||||
<TouchableOpacity
|
||||
style={[StyleSheet.absoluteFill, { zIndex: 100, elevation: 100 }]}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.9}
|
||||
/>
|
||||
)}
|
||||
<MedicationCard
|
||||
medication={item}
|
||||
colors={colors}
|
||||
selectedDate={selectedDate}
|
||||
onOpenDetails={isExpanded ? onOpenDetails : undefined} // Disable inner click when collapsed
|
||||
onCelebrate={onCelebrate}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 8,
|
||||
gap: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
stackContainer: {
|
||||
position: 'relative',
|
||||
// minHeight ensures space for the stack when collapsed
|
||||
},
|
||||
});
|
||||
205
components/medications/ExpiryDatePickerModal.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, Modal, Platform, Pressable, StyleSheet, View } from 'react-native';
|
||||
|
||||
interface ExpiryDatePickerModalProps {
|
||||
visible: boolean;
|
||||
currentDate: Date | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (date: Date) => void;
|
||||
isAiDraft?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效期日期选择器组件
|
||||
*
|
||||
* 功能:
|
||||
* - 显示日期选择器弹窗
|
||||
* - 验证日期不能早于今天
|
||||
* - iOS 显示内联日历,Android 显示原生对话框
|
||||
* - 支持取消和确认操作
|
||||
*/
|
||||
export function ExpiryDatePickerModal({
|
||||
visible,
|
||||
currentDate,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isAiDraft = false,
|
||||
}: ExpiryDatePickerModalProps) {
|
||||
const { t } = useI18n();
|
||||
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
|
||||
const colors = Colors[scheme];
|
||||
|
||||
// 内部状态:选择的日期值
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(currentDate || new Date());
|
||||
|
||||
// 当弹窗显示时,同步当前日期
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedDate(currentDate || new Date());
|
||||
}
|
||||
}, [visible, currentDate]);
|
||||
|
||||
/**
|
||||
* 处理日期变化
|
||||
* iOS: 实时更新选择的日期
|
||||
* Android: 在用户点击确定时直接确认
|
||||
*/
|
||||
const handleDateChange = useCallback(
|
||||
(event: any, date?: Date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS: 实时更新内部状态
|
||||
if (date) {
|
||||
setSelectedDate(date);
|
||||
}
|
||||
} else {
|
||||
// Android: 处理用户操作
|
||||
if (event.type === 'set' && date) {
|
||||
// 用户点击确定
|
||||
validateAndConfirm(date);
|
||||
} else {
|
||||
// 用户点击取消
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
/**
|
||||
* 验证并确认日期
|
||||
*/
|
||||
const validateAndConfirm = useCallback(
|
||||
(dateToConfirm: Date) => {
|
||||
// 验证有效期不能早于今天
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const selected = new Date(dateToConfirm);
|
||||
selected.setHours(0, 0, 0, 0);
|
||||
|
||||
if (selected < today) {
|
||||
Alert.alert('日期无效', '有效期不能早于今天');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查日期是否真的发生了变化
|
||||
const currentExpiry = currentDate ? dayjs(currentDate).format('YYYY-MM-DD') : null;
|
||||
const newExpiry = dayjs(dateToConfirm).format('YYYY-MM-DD');
|
||||
|
||||
if (currentExpiry === newExpiry) {
|
||||
// 日期没有变化,直接关闭
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// 日期有效且发生了变化,执行确认回调
|
||||
onConfirm(dateToConfirm);
|
||||
onClose();
|
||||
},
|
||||
[currentDate, onClose, onConfirm]
|
||||
);
|
||||
|
||||
/**
|
||||
* iOS 平台的确认按钮处理
|
||||
*/
|
||||
const handleIOSConfirm = useCallback(() => {
|
||||
validateAndConfirm(selectedDate);
|
||||
}, [selectedDate, validateAndConfirm]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} />
|
||||
<View style={[styles.sheet, { backgroundColor: colors.surface }]}>
|
||||
<ThemedText style={[styles.title, { color: colors.text }]}>
|
||||
选择有效期
|
||||
</ThemedText>
|
||||
|
||||
<DateTimePicker
|
||||
value={selectedDate}
|
||||
mode="date"
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={new Date()}
|
||||
onChange={handleDateChange}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
|
||||
{/* iOS 平台显示确认和取消按钮 */}
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={[styles.btn, { borderColor: colors.border }]}
|
||||
>
|
||||
<ThemedText style={[styles.btnText, { color: colors.textSecondary }]}>
|
||||
{t('medications.detail.pickers.cancel')}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleIOSConfirm}
|
||||
style={[styles.btn, styles.btnPrimary, { backgroundColor: colors.primary }]}
|
||||
>
|
||||
<ThemedText style={[styles.btnText, { color: colors.onPrimary }]}>
|
||||
{t('medications.detail.pickers.confirm')}
|
||||
</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
btn: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
btnPrimary: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
btnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
265
components/medications/MedicationPhotoGuideModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
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 React from 'react';
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
interface MedicationPhotoGuideModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 药品拍摄指南弹窗组件
|
||||
* 展示如何正确拍摄药品照片的说明和示例
|
||||
*/
|
||||
export function MedicationPhotoGuideModal({ visible, onClose }: MedicationPhotoGuideModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.modalOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={styles.guideModalContainer}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.guideModalContent}
|
||||
>
|
||||
{/* 标题部分 */}
|
||||
<View style={styles.guideHeader}>
|
||||
<Text style={styles.guideStepBadge}>规范</Text>
|
||||
<Text style={styles.guideTitle}>拍摄图片清晰</Text>
|
||||
</View>
|
||||
|
||||
{/* 示例图片 */}
|
||||
<View style={styles.guideImagesContainer}>
|
||||
{/* 正确示例 */}
|
||||
<View style={styles.guideImageWrapper}>
|
||||
<View style={styles.guideImageBox}>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={32}
|
||||
color="#4CAF50"
|
||||
style={styles.guideImageIcon}
|
||||
/>
|
||||
<Image
|
||||
source={require('@/assets/images/medicine/image-medicine.png')}
|
||||
style={styles.guideImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.guideImageIndicator}>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#4CAF50" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 错误示例 */}
|
||||
<View style={styles.guideImageWrapper}>
|
||||
<View style={[styles.guideImageBox, styles.guideImageBoxBlur]}>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={32}
|
||||
color="#F44336"
|
||||
style={styles.guideImageIcon}
|
||||
/>
|
||||
<Image
|
||||
source={require('@/assets/images/medicine/image-medicine.png')}
|
||||
style={[styles.guideImage, { opacity: 0.5 }]}
|
||||
contentFit="cover"
|
||||
blurRadius={8}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.guideImageIndicator, styles.guideImageIndicatorError]}>
|
||||
<Ionicons name="close-circle" size={20} color="#F44336" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 说明文字 */}
|
||||
<View style={styles.guideDescription}>
|
||||
<Text style={styles.guideDescriptionText}>
|
||||
请拍摄药品正面\背面的产品名称\说明部分。
|
||||
</Text>
|
||||
<Text style={styles.guideDescriptionText}>
|
||||
注意拍摄时光线充分,没有反光,文字部分清晰可见。照片的清晰度会影响识别的准确率。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.guideConfirmButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 179, 0, 0.9)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 179, 0, 0.95)', 'rgba(255, 160, 0, 0.95)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.guideConfirmButtonGradient}
|
||||
>
|
||||
<Text style={styles.guideConfirmButtonText}>知道了!</Text>
|
||||
</LinearGradient>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.guideConfirmButton}>
|
||||
<LinearGradient
|
||||
colors={['#FFB300', '#FFA000']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.guideConfirmButtonGradient}
|
||||
>
|
||||
<Text style={styles.guideConfirmButtonText}>知道了!</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
guideModalContainer: {
|
||||
width: SCREEN_WIDTH - 48,
|
||||
maxHeight: '80%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 20,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 10,
|
||||
},
|
||||
guideModalContent: {
|
||||
padding: 24,
|
||||
},
|
||||
guideHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
guideStepBadge: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#FFB300',
|
||||
marginBottom: 8,
|
||||
},
|
||||
guideTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
textAlign: 'center',
|
||||
},
|
||||
guideImagesContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
gap: 12,
|
||||
},
|
||||
guideImageWrapper: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
guideImageBox: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#f8fafc',
|
||||
position: 'relative',
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CAF50',
|
||||
},
|
||||
guideImageBoxBlur: {
|
||||
borderColor: '#F44336',
|
||||
},
|
||||
guideImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
guideImageIcon: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 1,
|
||||
},
|
||||
guideImageIndicator: {
|
||||
marginTop: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
},
|
||||
guideImageIndicatorError: {
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
},
|
||||
guideDescription: {
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
guideDescriptionText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 22,
|
||||
color: '#475569',
|
||||
marginBottom: 8,
|
||||
},
|
||||
guideConfirmButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#FFB300',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 6,
|
||||
},
|
||||
guideConfirmButtonGradient: {
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
guideConfirmButtonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Dimensions,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
@@ -24,6 +27,7 @@ interface ConfirmationSheetProps {
|
||||
cancelText?: string;
|
||||
destructive?: boolean;
|
||||
loading?: boolean;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ConfirmationSheet({
|
||||
@@ -36,11 +40,13 @@ export function ConfirmationSheet({
|
||||
cancelText = '取消',
|
||||
destructive = false,
|
||||
loading = false,
|
||||
content,
|
||||
}: ConfirmationSheetProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const translateY = useRef(new Animated.Value(screenHeight)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
const [modalVisible, setModalVisible] = useState(visible);
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -116,7 +122,10 @@ export function ConfirmationSheet({
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.overlay}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.backdrop,
|
||||
@@ -140,35 +149,67 @@ export function ConfirmationSheet({
|
||||
<View style={styles.handle} />
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||
{content}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
style={[styles.buttonContainer, loading && styles.disabledButton]}
|
||||
activeOpacity={0.85}
|
||||
onPress={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.cancelText}>{cancelText}</Text>
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(241, 245, 249, 0.6)"
|
||||
isInteractive
|
||||
>
|
||||
<Text style={styles.cancelText}>{cancelText}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.cancelButton}>
|
||||
<Text style={styles.cancelText}>{cancelText}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.confirmButton,
|
||||
destructive ? styles.destructiveButton : styles.primaryButton,
|
||||
loading && styles.disabledButton,
|
||||
]}
|
||||
style={[styles.buttonContainer, loading && styles.disabledButton]}
|
||||
activeOpacity={0.85}
|
||||
onPress={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor={destructive ? 'rgba(239, 68, 68, 0.85)' : 'rgba(37, 99, 235, 0.85)'}
|
||||
isInteractive
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.confirmButton,
|
||||
destructive ? styles.destructiveButton : styles.primaryButton,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.confirmText}>{confirmText}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -221,8 +262,17 @@ const styles = StyleSheet.create({
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
cancelButton: {
|
||||
buttonContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
glassButton: {
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cancelButton: {
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
@@ -237,7 +287,6 @@ const styles = StyleSheet.create({
|
||||
color: '#111827',
|
||||
},
|
||||
confirmButton: {
|
||||
flex: 1,
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -88,13 +88,19 @@ export function MedicalDisclaimerSheet({
|
||||
}, [visible, modalVisible, backdropOpacity, translateY]);
|
||||
|
||||
const handleCancel = () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => {
|
||||
console.warn('[MEDICATION] Haptic feedback failed:', error);
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (loading) return;
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => {
|
||||
console.warn('[MEDICATION] Haptic feedback failed:', error);
|
||||
});
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ export const ROUTES = {
|
||||
// 药品相关路由
|
||||
MEDICATION_EDIT_FREQUENCY: '/medications/edit-frequency',
|
||||
MEDICATION_MANAGE: '/medications/manage-medications',
|
||||
|
||||
// 底部栏配置路由
|
||||
TAB_BAR_CONFIG: '/settings/tab-bar-config',
|
||||
} as const;
|
||||
|
||||
// 路由参数常量
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
# 推送通知功能实现文档
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功集成本地推送通知功能,使用 Expo 官方的 `expo-notifications` 库。该功能支持立即通知、定时通知、重复通知等多种类型,并提供了完整的权限管理和通知处理机制。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **expo-notifications**: Expo 官方推送通知库
|
||||
- **React Native**: 跨平台移动应用框架
|
||||
- **TypeScript**: 类型安全的 JavaScript 超集
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
services/
|
||||
├── notifications.ts # 推送通知服务核心逻辑
|
||||
hooks/
|
||||
├── useNotifications.ts # 推送通知自定义 Hook
|
||||
components/
|
||||
├── NotificationTest.tsx # 通知功能测试组件
|
||||
app/(tabs)/
|
||||
├── personal.tsx # 个人页面(集成通知开关)
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 通知服务 (services/notifications.ts)
|
||||
|
||||
#### 主要特性
|
||||
- **单例模式**: 确保全局只有一个通知服务实例
|
||||
- **权限管理**: 自动请求和管理通知权限
|
||||
- **多种通知类型**: 支持立即、定时、重复通知
|
||||
- **通知监听**: 处理通知接收和点击事件
|
||||
- **便捷方法**: 提供常用通知类型的快捷发送方法
|
||||
|
||||
#### 核心方法
|
||||
|
||||
```typescript
|
||||
// 初始化通知服务
|
||||
await notificationService.initialize();
|
||||
|
||||
// 发送立即通知
|
||||
await notificationService.sendImmediateNotification({
|
||||
title: '标题',
|
||||
body: '内容',
|
||||
sound: true,
|
||||
priority: 'high'
|
||||
});
|
||||
|
||||
// 安排定时通知
|
||||
await notificationService.scheduleNotificationAtDate(
|
||||
notification,
|
||||
new Date(Date.now() + 5000) // 5秒后
|
||||
);
|
||||
|
||||
// 安排重复通知
|
||||
await notificationService.scheduleRepeatingNotification(
|
||||
notification,
|
||||
{ minutes: 1 } // 每分钟重复
|
||||
);
|
||||
|
||||
// 取消通知
|
||||
await notificationService.cancelNotification(notificationId);
|
||||
await notificationService.cancelAllNotifications();
|
||||
```
|
||||
|
||||
### 2. 自定义 Hook (hooks/useNotifications.ts)
|
||||
|
||||
#### 主要特性
|
||||
- **状态管理**: 管理通知权限和初始化状态
|
||||
- **自动初始化**: 组件挂载时自动初始化通知服务
|
||||
- **便捷接口**: 提供简化的通知操作方法
|
||||
- **类型安全**: 完整的 TypeScript 类型定义
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```typescript
|
||||
const {
|
||||
isInitialized,
|
||||
permissionStatus,
|
||||
sendNotification,
|
||||
scheduleNotification,
|
||||
sendWorkoutReminder,
|
||||
sendGoalAchievement,
|
||||
} = useNotifications();
|
||||
|
||||
// 发送运动提醒
|
||||
await sendWorkoutReminder('运动提醒', '该开始今天的普拉提训练了!');
|
||||
|
||||
// 发送目标达成通知
|
||||
await sendGoalAchievement('目标达成', '恭喜您完成了本周的运动目标!');
|
||||
```
|
||||
|
||||
### 3. 测试组件 (components/NotificationTest.tsx)
|
||||
|
||||
#### 功能特性
|
||||
- **完整测试**: 测试所有通知功能
|
||||
- **状态显示**: 显示初始化状态和权限状态
|
||||
- **交互测试**: 提供各种通知类型的测试按钮
|
||||
- **通知列表**: 显示已安排的通知列表
|
||||
|
||||
## 配置说明
|
||||
|
||||
### app.json 配置
|
||||
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"plugins": [
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/images/Sealife.jpeg",
|
||||
"color": "#ffffff",
|
||||
"sounds": ["./assets/sounds/notification.wav"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["remote-notification"]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"permissions": [
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.VIBRATE",
|
||||
"android.permission.WAKE_LOCK"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 1. 运动提醒
|
||||
```typescript
|
||||
// 每天定时发送运动提醒
|
||||
await scheduleRepeatingNotification(
|
||||
{
|
||||
title: '运动提醒',
|
||||
body: '该开始今天的普拉提训练了!',
|
||||
data: { type: 'workout_reminder' },
|
||||
sound: true,
|
||||
priority: 'high'
|
||||
},
|
||||
{ days: 1 }
|
||||
);
|
||||
```
|
||||
|
||||
### 2. 目标达成通知
|
||||
```typescript
|
||||
// 用户达成目标时立即发送通知
|
||||
await sendGoalAchievement('目标达成', '恭喜您完成了本周的运动目标!');
|
||||
```
|
||||
|
||||
### 3. 心情打卡提醒
|
||||
```typescript
|
||||
// 每天晚上提醒用户记录心情
|
||||
const eveningTime = new Date();
|
||||
eveningTime.setHours(20, 0, 0, 0);
|
||||
|
||||
await scheduleNotification(
|
||||
{
|
||||
title: '心情打卡',
|
||||
body: '记得记录今天的心情状态哦',
|
||||
data: { type: 'mood_checkin' },
|
||||
sound: true,
|
||||
priority: 'normal'
|
||||
},
|
||||
eveningTime
|
||||
);
|
||||
```
|
||||
|
||||
### 4. 营养提醒
|
||||
```typescript
|
||||
// 定时提醒用户记录饮食
|
||||
await scheduleRepeatingNotification(
|
||||
{
|
||||
title: '营养记录',
|
||||
body: '记得记录今天的饮食情况',
|
||||
data: { type: 'nutrition_reminder' },
|
||||
sound: true,
|
||||
priority: 'normal'
|
||||
},
|
||||
{ hours: 4 } // 每4小时提醒一次
|
||||
);
|
||||
```
|
||||
|
||||
## 权限处理
|
||||
|
||||
### iOS 权限
|
||||
- 自动请求通知权限
|
||||
- 支持后台通知模式
|
||||
- 处理权限被拒绝的情况
|
||||
|
||||
### Android 权限
|
||||
- 自动请求必要权限
|
||||
- 支持开机启动和唤醒锁
|
||||
- 处理权限被拒绝的情况
|
||||
|
||||
## 通知处理
|
||||
|
||||
### 通知接收处理
|
||||
```typescript
|
||||
Notifications.addNotificationReceivedListener((notification) => {
|
||||
console.log('收到通知:', notification);
|
||||
// 可以在这里处理通知接收逻辑
|
||||
});
|
||||
```
|
||||
|
||||
### 通知点击处理
|
||||
```typescript
|
||||
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
const { notification } = response;
|
||||
const data = notification.request.content.data;
|
||||
|
||||
// 根据通知类型处理不同的逻辑
|
||||
if (data?.type === 'workout_reminder') {
|
||||
// 跳转到运动页面
|
||||
} else if (data?.type === 'goal_achievement') {
|
||||
// 跳转到目标页面
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 通知内容
|
||||
- 标题简洁明了,不超过50个字符
|
||||
- 内容具体有用,不超过200个字符
|
||||
- 使用适当的优先级和声音
|
||||
|
||||
### 2. 定时策略
|
||||
- 避免过于频繁的通知
|
||||
- 考虑用户的使用习惯
|
||||
- 提供通知频率设置选项
|
||||
|
||||
### 3. 错误处理
|
||||
- 始终处理权限请求失败的情况
|
||||
- 提供用户友好的错误提示
|
||||
- 记录通知发送失败的原因
|
||||
|
||||
### 4. 性能优化
|
||||
- 避免同时发送大量通知
|
||||
- 及时清理不需要的通知
|
||||
- 合理使用重复通知
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 功能测试
|
||||
- 测试所有通知类型
|
||||
- 验证权限请求流程
|
||||
- 检查通知点击处理
|
||||
|
||||
### 2. 兼容性测试
|
||||
- 测试不同 iOS 版本
|
||||
- 测试不同 Android 版本
|
||||
- 验证后台通知功能
|
||||
|
||||
### 3. 用户体验测试
|
||||
- 测试通知时机是否合适
|
||||
- 验证通知内容是否清晰
|
||||
- 检查通知频率是否合理
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **通知不显示**
|
||||
- 检查权限是否已授予
|
||||
- 确认应用是否在前台
|
||||
- 验证通知配置是否正确
|
||||
|
||||
2. **定时通知不触发**
|
||||
- 检查设备是否重启
|
||||
- 确认应用是否被系统杀死
|
||||
- 验证时间设置是否正确
|
||||
|
||||
3. **权限被拒绝**
|
||||
- 引导用户到系统设置
|
||||
- 提供权限说明
|
||||
- 实现降级处理方案
|
||||
|
||||
### 调试技巧
|
||||
|
||||
```typescript
|
||||
// 启用详细日志
|
||||
console.log('通知权限状态:', await notificationService.getPermissionStatus());
|
||||
console.log('已安排通知:', await notificationService.getAllScheduledNotifications());
|
||||
|
||||
// 测试通知发送
|
||||
await notificationService.sendImmediateNotification({
|
||||
title: '测试通知',
|
||||
body: '这是一个测试通知',
|
||||
sound: true
|
||||
});
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
本推送通知功能实现完整、功能丰富,支持多种通知类型和场景。通过合理的架构设计和错误处理,确保了功能的稳定性和用户体验。开发者可以根据具体需求灵活使用各种通知功能,为用户提供个性化的提醒服务。
|
||||
@@ -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;
|
||||
|
||||
454
i18n/index.ts
@@ -47,6 +47,7 @@ const personalScreenResources = {
|
||||
language: '语言',
|
||||
healthData: '健康数据授权',
|
||||
medicalSources: '医学建议来源',
|
||||
customization: '个性化',
|
||||
},
|
||||
menu: {
|
||||
notificationSettings: '通知设置',
|
||||
@@ -59,6 +60,7 @@ const personalScreenResources = {
|
||||
deleteAccount: '注销帐号',
|
||||
healthDataPermissions: '健康数据授权说明',
|
||||
whoSource: '世界卫生组织 (WHO)',
|
||||
tabBarConfig: '底部栏配置',
|
||||
},
|
||||
language: {
|
||||
title: '语言',
|
||||
@@ -77,6 +79,20 @@ const personalScreenResources = {
|
||||
},
|
||||
},
|
||||
},
|
||||
tabBarConfig: {
|
||||
title: '底部栏配置',
|
||||
subtitle: '自定义你的底部导航栏',
|
||||
description: '使用开关控制标签的显示和隐藏',
|
||||
resetButton: '恢复默认',
|
||||
cannotDisable: '此标签不可关闭',
|
||||
resetConfirm: {
|
||||
title: '恢复默认设置?',
|
||||
message: '将重置所有底部栏配置和显示状态',
|
||||
cancel: '取消',
|
||||
confirm: '确认恢复',
|
||||
},
|
||||
resetSuccess: '已恢复默认设置',
|
||||
},
|
||||
};
|
||||
|
||||
const badgesScreenResources = {
|
||||
@@ -443,6 +459,38 @@ const statisticsResources = {
|
||||
challenges: '挑战',
|
||||
personal: '个人',
|
||||
},
|
||||
activityHeatMap: {
|
||||
subtitle: '最近6个月活跃 {{days}} 天',
|
||||
activeRate: '{{rate}}%',
|
||||
popover: {
|
||||
title: '能量值的积攒后续可以用来兑换 AI 相关权益',
|
||||
subtitle: '获取说明',
|
||||
rules: {
|
||||
login: '1. 每日登录获得能量值+1',
|
||||
mood: '2. 每日记录心情获得能量值+1',
|
||||
diet: '3. 记饮食获得能量值+1',
|
||||
goal: '4. 完成一次目标获得能量值+1',
|
||||
},
|
||||
},
|
||||
months: {
|
||||
1: '1月',
|
||||
2: '2月',
|
||||
3: '3月',
|
||||
4: '4月',
|
||||
5: '5月',
|
||||
6: '6月',
|
||||
7: '7月',
|
||||
8: '8月',
|
||||
9: '9月',
|
||||
10: '10月',
|
||||
11: '11月',
|
||||
12: '12月',
|
||||
},
|
||||
legend: {
|
||||
less: '少',
|
||||
more: '多',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const medicationsResources = {
|
||||
@@ -458,6 +506,9 @@ const medicationsResources = {
|
||||
title: '今日暂无用药安排',
|
||||
subtitle: '还未添加任何用药计划,快来补充吧。',
|
||||
},
|
||||
stack: {
|
||||
completed: '已完成 ({{count}})',
|
||||
},
|
||||
dateFormats: {
|
||||
today: '今天,{{date}}',
|
||||
other: '{{date}}',
|
||||
@@ -472,8 +523,16 @@ const medicationsResources = {
|
||||
action: {
|
||||
takeNow: '立即服用',
|
||||
taken: '已服用',
|
||||
skipped: '已跳过',
|
||||
skip: '跳过',
|
||||
submitting: '提交中...',
|
||||
},
|
||||
skipAlert: {
|
||||
title: '确认跳过',
|
||||
message: '确定要跳过本次用药吗?\n\n跳过后将不会记录为已服用。',
|
||||
cancel: '取消',
|
||||
confirm: '确认跳过',
|
||||
},
|
||||
earlyTakeAlert: {
|
||||
title: '尚未到服药时间',
|
||||
message: '该用药计划在 {{time}},现在还早于1小时以上。\n\n是否确认已服用此药物?',
|
||||
@@ -485,6 +544,11 @@ const medicationsResources = {
|
||||
message: '记录服药时发生错误,请稍后重试',
|
||||
confirm: '确定',
|
||||
},
|
||||
skipError: {
|
||||
title: '操作失败',
|
||||
message: '跳过操作失败,请稍后重试',
|
||||
confirm: '确定',
|
||||
},
|
||||
},
|
||||
// 添加药物页面翻译
|
||||
add: {
|
||||
@@ -620,6 +684,7 @@ const medicationsResources = {
|
||||
formLabels: {
|
||||
capsule: '胶囊',
|
||||
pill: '药片',
|
||||
tablet: '药片',
|
||||
injection: '注射',
|
||||
spray: '喷雾',
|
||||
drop: '滴剂',
|
||||
@@ -658,6 +723,7 @@ const medicationsResources = {
|
||||
period: '服药周期',
|
||||
time: '用药时间',
|
||||
frequency: '频率',
|
||||
expiryDate: '药品有效期',
|
||||
longTerm: '长期',
|
||||
periodMessage: '开始服药日期:{{startDate}}\n{{endDateInfo}}',
|
||||
longTermPlan: '服药计划:长期服药',
|
||||
@@ -785,12 +851,230 @@ 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: '加载中...',
|
||||
sections: {
|
||||
notifications: '通知设置',
|
||||
medicationReminder: '药品提醒',
|
||||
nutritionReminder: '营养提醒',
|
||||
moodReminder: '心情提醒',
|
||||
description: '说明',
|
||||
},
|
||||
items: {
|
||||
@@ -802,9 +1086,17 @@ const notificationSettingsResources = {
|
||||
title: '药品通知提醒',
|
||||
description: '在用药时间接收提醒通知',
|
||||
},
|
||||
nutritionReminder: {
|
||||
title: '营养记录提醒',
|
||||
description: '在用餐时间接收营养记录提醒',
|
||||
},
|
||||
moodReminder: {
|
||||
title: '心情记录提醒',
|
||||
description: '在晚间接收心情记录提醒',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
text: '• 消息推送是所有通知的总开关\n• 药品通知提醒需要在消息推送开启后才能使用\n• 您可以在系统设置中管理通知权限\n• 关闭消息推送将停止所有应用通知',
|
||||
text: '• 消息推送是所有通知的总开关\n• 各类提醒需要在消息推送开启后才能使用\n• 您可以在系统设置中管理通知权限\n• 关闭消息推送将停止所有应用通知',
|
||||
},
|
||||
alerts: {
|
||||
permissionDenied: {
|
||||
@@ -818,6 +1110,8 @@ const notificationSettingsResources = {
|
||||
message: '请求通知权限失败',
|
||||
saveFailed: '保存设置失败',
|
||||
medicationReminderFailed: '设置药品提醒失败',
|
||||
nutritionReminderFailed: '设置营养提醒失败',
|
||||
moodReminderFailed: '设置心情提醒失败',
|
||||
},
|
||||
notificationsEnabled: {
|
||||
title: '通知已开启',
|
||||
@@ -827,6 +1121,14 @@ const notificationSettingsResources = {
|
||||
title: '药品提醒已开启',
|
||||
body: '您将在用药时间收到提醒通知',
|
||||
},
|
||||
nutritionReminderEnabled: {
|
||||
title: '营养提醒已开启',
|
||||
body: '您将在用餐时间收到营养记录提醒',
|
||||
},
|
||||
moodReminderEnabled: {
|
||||
title: '心情提醒已开启',
|
||||
body: '您将在晚间收到心情记录提醒',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -840,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: {
|
||||
@@ -881,6 +1214,7 @@ const resources = {
|
||||
language: 'Language',
|
||||
healthData: 'Health data permissions',
|
||||
medicalSources: 'Medical Advice Sources',
|
||||
customization: 'Customization',
|
||||
},
|
||||
menu: {
|
||||
notificationSettings: 'Notification settings',
|
||||
@@ -893,6 +1227,7 @@ const resources = {
|
||||
deleteAccount: 'Delete account',
|
||||
healthDataPermissions: 'Health data disclosure',
|
||||
whoSource: 'World Health Organization (WHO)',
|
||||
tabBarConfig: 'Tab Bar Settings',
|
||||
},
|
||||
language: {
|
||||
title: 'Language',
|
||||
@@ -1197,6 +1532,38 @@ const resources = {
|
||||
challenges: 'Challenges',
|
||||
personal: 'Me',
|
||||
},
|
||||
activityHeatMap: {
|
||||
subtitle: 'Active {{days}} days in the last 6 months',
|
||||
activeRate: '{{rate}}%',
|
||||
popover: {
|
||||
title: 'Accumulated energy can be redeemed for AI-related benefits',
|
||||
subtitle: 'How to earn',
|
||||
rules: {
|
||||
login: '1. Daily login earns energy +1',
|
||||
mood: '2. Daily mood record earns energy +1',
|
||||
diet: '3. Diet record earns energy +1',
|
||||
goal: '4. Complete a goal earns energy +1',
|
||||
},
|
||||
},
|
||||
months: {
|
||||
1: 'Jan',
|
||||
2: 'Feb',
|
||||
3: 'Mar',
|
||||
4: 'Apr',
|
||||
5: 'May',
|
||||
6: 'Jun',
|
||||
7: 'Jul',
|
||||
8: 'Aug',
|
||||
9: 'Sep',
|
||||
10: 'Oct',
|
||||
11: 'Nov',
|
||||
12: 'Dec',
|
||||
},
|
||||
legend: {
|
||||
less: 'Less',
|
||||
more: 'More',
|
||||
},
|
||||
},
|
||||
},
|
||||
medications: {
|
||||
greeting: 'Hello, {{name}}',
|
||||
@@ -1211,6 +1578,9 @@ const resources = {
|
||||
title: 'No medications scheduled for today',
|
||||
subtitle: 'No medication plans added yet. Let\'s add some.',
|
||||
},
|
||||
stack: {
|
||||
completed: 'Completed ({{count}})',
|
||||
},
|
||||
dateFormats: {
|
||||
today: 'Today, {{date}}',
|
||||
other: '{{date}}',
|
||||
@@ -1225,8 +1595,16 @@ const resources = {
|
||||
action: {
|
||||
takeNow: 'Take Now',
|
||||
taken: 'Taken',
|
||||
skipped: 'Skipped',
|
||||
skip: 'Skip',
|
||||
submitting: 'Submitting...',
|
||||
},
|
||||
skipAlert: {
|
||||
title: 'Confirm Skip',
|
||||
message: 'Are you sure you want to skip this medication?\n\nIt will not be recorded as taken.',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm Skip',
|
||||
},
|
||||
earlyTakeAlert: {
|
||||
title: 'Not yet time to take medication',
|
||||
message: 'This medication is scheduled for {{time}}, which is more than 1 hour from now.\n\nHave you already taken this medication?',
|
||||
@@ -1238,6 +1616,11 @@ const resources = {
|
||||
message: 'An error occurred while recording medication, please try again later',
|
||||
confirm: 'OK',
|
||||
},
|
||||
skipError: {
|
||||
title: 'Operation Failed',
|
||||
message: 'Skip operation failed, please try again later',
|
||||
confirm: 'OK',
|
||||
},
|
||||
},
|
||||
// 添加药物页面翻译
|
||||
add: {
|
||||
@@ -1373,6 +1756,7 @@ const resources = {
|
||||
formLabels: {
|
||||
capsule: 'Capsule',
|
||||
pill: 'Tablet',
|
||||
tablet: 'Tablet',
|
||||
injection: 'Injection',
|
||||
spray: 'Spray',
|
||||
drop: 'Drops',
|
||||
@@ -1411,6 +1795,7 @@ const resources = {
|
||||
period: 'Medication Period',
|
||||
time: 'Medication Time',
|
||||
frequency: 'Frequency',
|
||||
expiryDate: 'Expiry Date',
|
||||
longTerm: 'Long-term',
|
||||
periodMessage: 'Start date: {{startDate}}\n{{endDateInfo}}',
|
||||
longTermPlan: 'Medication plan: Long-term medication',
|
||||
@@ -1543,6 +1928,8 @@ const resources = {
|
||||
sections: {
|
||||
notifications: 'Notification Settings',
|
||||
medicationReminder: 'Medication Reminder',
|
||||
nutritionReminder: 'Nutrition Reminder',
|
||||
moodReminder: 'Mood Reminder',
|
||||
description: 'Description',
|
||||
},
|
||||
items: {
|
||||
@@ -1554,9 +1941,17 @@ const resources = {
|
||||
title: 'Medication Reminder',
|
||||
description: 'Receive reminder notifications at medication time',
|
||||
},
|
||||
nutritionReminder: {
|
||||
title: 'Nutrition Record Reminder',
|
||||
description: 'Receive nutrition record reminders at meal times',
|
||||
},
|
||||
moodReminder: {
|
||||
title: 'Mood Record Reminder',
|
||||
description: 'Receive mood record reminders in the evening',
|
||||
},
|
||||
},
|
||||
description: {
|
||||
text: '• Push notifications is the master switch for all notifications\n• Medication reminder requires push notifications to be enabled\n• You can manage notification permissions in system settings\n• Disabling push notifications will stop all app notifications',
|
||||
text: '• Push notifications is the master switch for all notifications\n• Various reminders require push notifications to be enabled\n• You can manage notification permissions in system settings\n• Disabling push notifications will stop all app notifications',
|
||||
},
|
||||
alerts: {
|
||||
permissionDenied: {
|
||||
@@ -1570,6 +1965,8 @@ const resources = {
|
||||
message: 'Failed to request notification permission',
|
||||
saveFailed: 'Failed to save settings',
|
||||
medicationReminderFailed: 'Failed to set medication reminder',
|
||||
nutritionReminderFailed: 'Failed to set nutrition reminder',
|
||||
moodReminderFailed: 'Failed to set mood reminder',
|
||||
},
|
||||
notificationsEnabled: {
|
||||
title: 'Notifications Enabled',
|
||||
@@ -1579,6 +1976,59 @@ const resources = {
|
||||
title: 'Medication Reminder Enabled',
|
||||
body: 'You will receive reminder notifications at medication time',
|
||||
},
|
||||
nutritionReminderEnabled: {
|
||||
title: 'Nutrition Reminder Enabled',
|
||||
body: 'You will receive nutrition record reminders at meal times',
|
||||
},
|
||||
moodReminderEnabled: {
|
||||
title: 'Mood Reminder Enabled',
|
||||
body: 'You will receive mood record reminders in the evening',
|
||||
},
|
||||
},
|
||||
},
|
||||
tabBarConfig: {
|
||||
title: 'Tab Bar Settings',
|
||||
subtitle: 'Customize your bottom navigation',
|
||||
description: 'Use toggle to show or hide tabs',
|
||||
resetButton: 'Reset to Default',
|
||||
cannotDisable: 'This tab cannot be disabled',
|
||||
resetConfirm: {
|
||||
title: 'Reset to default?',
|
||||
message: 'This will reset all tab bar settings and visibility',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Reset',
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
20
ios/AppStoreReviewManager.m
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// AppStoreReviewManager.m
|
||||
// OutLive
|
||||
//
|
||||
// Objective-C 桥接文件:将 Swift 类暴露给 React Native
|
||||
//
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(AppStoreReviewManager, NSObject)
|
||||
|
||||
// 请求应用内评分
|
||||
RCT_EXTERN_METHOD(requestReview:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// 检查是否可以请求评分
|
||||
RCT_EXTERN_METHOD(canRequestReview:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
85
ios/AppStoreReviewManager.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// AppStoreReviewManager.swift
|
||||
// OutLive
|
||||
//
|
||||
// iOS 原生模块:应用内评分管理
|
||||
// 使用 StoreKit 的 SKStoreReviewController 来请求应用评分
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import React
|
||||
|
||||
@objc(AppStoreReviewManager)
|
||||
class AppStoreReviewManager: NSObject {
|
||||
|
||||
@objc
|
||||
static func moduleName() -> String! {
|
||||
return "AppStoreReviewManager"
|
||||
}
|
||||
|
||||
/// 请求应用内评分
|
||||
/// 这是唯一的公开方法,由 JavaScript 层调用
|
||||
/// 注意:iOS 系统会自动管理评分请求的频率限制
|
||||
@objc
|
||||
func requestReview(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
// 检查 iOS 版本(SKStoreReviewController 需要 iOS 14.0+)
|
||||
if #available(iOS 14.0, *) {
|
||||
// 获取当前活动的场景
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
// 请求评分
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
|
||||
resolver([
|
||||
"success": true,
|
||||
"message": "Review request sent successfully"
|
||||
])
|
||||
} else {
|
||||
rejecter(
|
||||
"NO_SCENE",
|
||||
"Unable to find active window scene",
|
||||
nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// iOS 14 以下不支持,返回错误
|
||||
rejecter(
|
||||
"VERSION_NOT_SUPPORTED",
|
||||
"SKStoreReviewController requires iOS 14.0 or later",
|
||||
nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否可以请求评分
|
||||
/// 虽然 iOS 系统会自动限制,但这个方法可以让 JS 层做额外判断
|
||||
@objc
|
||||
func canRequestReview(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
// iOS 14.0+ 支持应用内评分
|
||||
if #available(iOS 14.0, *) {
|
||||
resolver([
|
||||
"canRequest": true,
|
||||
"systemVersion": UIDevice.current.systemVersion
|
||||
])
|
||||
} else {
|
||||
resolver([
|
||||
"canRequest": false,
|
||||
"systemVersion": UIDevice.current.systemVersion,
|
||||
"reason": "Requires iOS 14.0 or later"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
792C52592EA880A7002F3F09 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792C52582EA880A7002F3F09 /* StoreKit.framework */; };
|
||||
792C52622EB05B8F002F3F09 /* NativeToastManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 792C52602EB05B8F002F3F09 /* NativeToastManager.m */; };
|
||||
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792C52612EB05B8F002F3F09 /* NativeToastManager.swift */; };
|
||||
794DD5D62ED3E3BB0046E2B4 /* AppStoreReviewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */; };
|
||||
794DD5D72ED3E3BB0046E2B4 /* AppStoreReviewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */; };
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
||||
@@ -64,6 +66,8 @@
|
||||
792C52582EA880A7002F3F09 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
|
||||
792C52602EB05B8F002F3F09 /* NativeToastManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = NativeToastManager.m; path = OutLive/NativeToastManager.m; sourceTree = "<group>"; };
|
||||
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = NativeToastManager.swift; path = OutLive/NativeToastManager.swift; sourceTree = "<group>"; };
|
||||
794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppStoreReviewManager.m; sourceTree = "<group>"; };
|
||||
794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreReviewManager.swift; sourceTree = "<group>"; };
|
||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
79E80BA22EC5D92A004425BE /* medicineExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = medicineExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -177,6 +181,8 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
794DD5D42ED3E3BB0046E2B4 /* AppStoreReviewManager.m */,
|
||||
794DD5D52ED3E3BB0046E2B4 /* AppStoreReviewManager.swift */,
|
||||
79E80BFB2EC5E127004425BE /* AppGroupUserDefaultsManager.h */,
|
||||
79E80BFC2EC5E127004425BE /* AppGroupUserDefaultsManager.m */,
|
||||
79E80BFD2EC5E127004425BE /* WidgetManager.h */,
|
||||
@@ -501,6 +507,8 @@
|
||||
B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */,
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
794DD5D62ED3E3BB0046E2B4 /* AppStoreReviewManager.m in Sources */,
|
||||
794DD5D72ED3E3BB0046E2B4 /* AppStoreReviewManager.swift in Sources */,
|
||||
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
20
ios/OutLive/AppStoreReviewManager.m
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// AppStoreReviewManager.m
|
||||
// OutLive
|
||||
//
|
||||
// Objective-C 桥接文件:将 Swift 类暴露给 React Native
|
||||
//
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(AppStoreReviewManager, NSObject)
|
||||
|
||||
// 请求应用内评分
|
||||
RCT_EXTERN_METHOD(requestReview:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
// 检查是否可以请求评分
|
||||
RCT_EXTERN_METHOD(canRequestReview:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
85
ios/OutLive/AppStoreReviewManager.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// AppStoreReviewManager.swift
|
||||
// OutLive
|
||||
//
|
||||
// iOS 原生模块:应用内评分管理
|
||||
// 使用 StoreKit 的 SKStoreReviewController 来请求应用评分
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
import React
|
||||
|
||||
@objc(AppStoreReviewManager)
|
||||
class AppStoreReviewManager: NSObject {
|
||||
|
||||
@objc
|
||||
static func moduleName() -> String! {
|
||||
return "AppStoreReviewManager"
|
||||
}
|
||||
|
||||
/// 请求应用内评分
|
||||
/// 这是唯一的公开方法,由 JavaScript 层调用
|
||||
/// 注意:iOS 系统会自动管理评分请求的频率限制
|
||||
@objc
|
||||
func requestReview(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
// 检查 iOS 版本(SKStoreReviewController 需要 iOS 14.0+)
|
||||
if #available(iOS 14.0, *) {
|
||||
// 获取当前活动的场景
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
// 请求评分
|
||||
SKStoreReviewController.requestReview(in: scene)
|
||||
|
||||
resolver([
|
||||
"success": true,
|
||||
"message": "Review request sent successfully"
|
||||
])
|
||||
} else {
|
||||
rejecter(
|
||||
"NO_SCENE",
|
||||
"Unable to find active window scene",
|
||||
nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// iOS 14 以下不支持,返回错误
|
||||
rejecter(
|
||||
"VERSION_NOT_SUPPORTED",
|
||||
"SKStoreReviewController requires iOS 14.0 or later",
|
||||
nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否可以请求评分
|
||||
/// 虽然 iOS 系统会自动限制,但这个方法可以让 JS 层做额外判断
|
||||
@objc
|
||||
func canRequestReview(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
// iOS 14.0+ 支持应用内评分
|
||||
if #available(iOS 14.0, *) {
|
||||
resolver([
|
||||
"canRequest": true,
|
||||
"systemVersion": UIDevice.current.systemVersion
|
||||
])
|
||||
} else {
|
||||
resolver([
|
||||
"canRequest": false,
|
||||
"systemVersion": UIDevice.current.systemVersion,
|
||||
"reason": "Requires iOS 14.0 or later"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 672 KiB |
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo.png",
|
||||
"filename" : "onBoarding.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo 1.png",
|
||||
"filename" : "onBoarding 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo 2.png",
|
||||
"filename" : "onBoarding 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 225 KiB |
BIN
ios/OutLive/Images.xcassets/SplashScreenLogo.imageset/onBoarding 1.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
ios/OutLive/Images.xcassets/SplashScreenLogo.imageset/onBoarding 2.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
ios/OutLive/Images.xcassets/SplashScreenLogo.imageset/onBoarding.png
vendored
Normal file
|
After Width: | Height: | Size: 124 KiB |
@@ -27,7 +27,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.27</string>
|
||||
<string>1.1.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EXPO-SCENE-1">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
|
||||
@@ -17,30 +17,27 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<rect key="frame" x="176.5" y="406" width="40" height="40"/>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" misplaced="YES" image="SplashScreenLogo" translatesAutoresizingMaskIntoConstraints="NO" id="EXPO-SplashScreen" userLabel="SplashScreenLogo">
|
||||
<rect key="frame" x="81" y="315" width="230" height="223"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
||||
<constraints>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
|
||||
</constraints>
|
||||
<color key="backgroundColor" name="SplashScreenBackground"/>
|
||||
<constraints>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="EXPO-PLACEHOLDER-1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="0.0" y="0.0"/>
|
||||
<point key="canvasLocation" x="-0.76335877862595414" y="0.0"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="SplashScreenLogo" width="40" height="40"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<image name="SplashScreenLogo" width="341.33334350585938" height="341.33334350585938"/>
|
||||
<namedColor name="SplashScreenBackground">
|
||||
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
|
||||
<color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -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
33
package.json
@@ -11,27 +11,28 @@
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/ui": "~0.2.0-beta.7",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-community/datetimepicker": "8.5.1",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "2.11.1",
|
||||
"@react-native-picker/picker": "2.11.4",
|
||||
"@react-native-voice/voice": "^3.2.4",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.4",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@sentry/react-native": "~7.2.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"dayjs": "^1.11.18",
|
||||
"expo": "54.0.21",
|
||||
"@react-navigation/bottom-tabs": "^7.8.6",
|
||||
"@react-navigation/elements": "^2.8.3",
|
||||
"@react-navigation/native": "^7.1.21",
|
||||
"@reduxjs/toolkit": "^2.11.0",
|
||||
"@sentry/react-native": "~7.7.0",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "54.0.25",
|
||||
"expo-apple-authentication": "~8.0.7",
|
||||
"expo-background-task": "~1.0.8",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-camera": "~17.0.8",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-glass-effect": "~0.1.5",
|
||||
"expo-glass-effect": "~0.1.7",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
@@ -41,8 +42,8 @@
|
||||
"expo-media-library": "^18.2.0",
|
||||
"expo-notifications": "~0.32.12",
|
||||
"expo-quick-actions": "^6.0.0",
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-router": "~6.0.15",
|
||||
"expo-splash-screen": "~31.0.11",
|
||||
"expo-sqlite": "^16.0.8",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
|
||||
118
scripts/check-app-review-setup.sh
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
|
||||
# iOS 应用内评分功能配置检查脚本
|
||||
# 使用方法: bash scripts/check-app-review-setup.sh
|
||||
|
||||
echo "================================================"
|
||||
echo "iOS 应用内评分功能 - 配置检查"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 检查计数
|
||||
checks_passed=0
|
||||
checks_failed=0
|
||||
|
||||
# 函数:检查文件存在
|
||||
check_file() {
|
||||
local file=$1
|
||||
local description=$2
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
echo -e "${GREEN}✓${NC} $description"
|
||||
echo " 路径: $file"
|
||||
((checks_passed++))
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗${NC} $description"
|
||||
echo " 路径: $file (文件不存在)"
|
||||
((checks_failed++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "步骤 1: 检查原生模块文件"
|
||||
echo "-----------------------------------"
|
||||
check_file "ios/OutLive/AppStoreReviewManager.swift" "Swift 实现文件"
|
||||
check_file "ios/OutLive/AppStoreReviewManager.m" "Objective-C 桥接文件"
|
||||
check_file "ios/OutLive/OutLive-Bridging-Header.h" "Bridging Header 文件"
|
||||
echo ""
|
||||
|
||||
echo "步骤 2: 检查服务层文件"
|
||||
echo "-----------------------------------"
|
||||
check_file "services/appStoreReview.ts" "评分请求管理服务"
|
||||
echo ""
|
||||
|
||||
echo "步骤 3: 检查 Redux 集成"
|
||||
echo "-----------------------------------"
|
||||
if check_file "store/challengesSlice.ts" "挑战 Slice 集成"; then
|
||||
if grep -q "appStoreReviewService" "store/challengesSlice.ts"; then
|
||||
echo -e " ${GREEN}✓${NC} 已集成应用评分服务"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} 未检测到应用评分服务集成"
|
||||
((checks_failed++))
|
||||
fi
|
||||
fi
|
||||
|
||||
if check_file "store/medicationsSlice.ts" "用药 Slice 集成"; then
|
||||
if grep -q "appStoreReviewService" "store/medicationsSlice.ts"; then
|
||||
echo -e " ${GREEN}✓${NC} 已集成应用评分服务"
|
||||
else
|
||||
echo -e " ${YELLOW}!${NC} 未检测到应用评分服务集成"
|
||||
((checks_failed++))
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "步骤 4: 检查文档"
|
||||
echo "-----------------------------------"
|
||||
check_file "docs/app-store-review-implementation.md" "实现文档"
|
||||
check_file "docs/app-store-review-xcode-setup.md" "Xcode 配置指南"
|
||||
echo ""
|
||||
|
||||
# 总结
|
||||
echo "================================================"
|
||||
echo "检查总结"
|
||||
echo "================================================"
|
||||
echo -e "通过: ${GREEN}$checks_passed${NC}"
|
||||
echo -e "失败: ${RED}$checks_failed${NC}"
|
||||
echo ""
|
||||
|
||||
# 根据结果给出建议
|
||||
if [ $checks_failed -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ 所有文件检查通过!${NC}"
|
||||
echo ""
|
||||
echo "下一步操作:"
|
||||
echo "1. 打开 Xcode 项目:"
|
||||
echo " cd ios && open OutLive.xcworkspace"
|
||||
echo ""
|
||||
echo "2. 在 Xcode 中添加原生模块文件(详见文档):"
|
||||
echo " - AppStoreReviewManager.swift"
|
||||
echo " - AppStoreReviewManager.m"
|
||||
echo ""
|
||||
echo "3. 清理并重新构建:"
|
||||
echo " Product > Clean Build Folder (Shift+Cmd+K)"
|
||||
echo " Product > Build (Cmd+B)"
|
||||
echo ""
|
||||
echo "4. 运行应用进行测试"
|
||||
echo ""
|
||||
echo "详细步骤请参考: docs/app-store-review-xcode-setup.md"
|
||||
else
|
||||
echo -e "${RED}✗ 检查未通过,请修复以上问题${NC}"
|
||||
echo ""
|
||||
echo "常见问题:"
|
||||
echo "- 如果文件不存在,请确认文件是否被正确创建"
|
||||
echo "- 如果集成检查失败,请检查代码是否正确导入和使用服务"
|
||||
echo ""
|
||||
echo "获取帮助:"
|
||||
echo "- 查看实现文档: docs/app-store-review-implementation.md"
|
||||
echo "- 查看配置指南: docs/app-store-review-xcode-setup.md"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "================================================"
|
||||
@@ -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 = {
|
||||
@@ -119,6 +143,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
@@ -128,8 +153,6 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
console.log('json', json);
|
||||
|
||||
if (!response.ok) {
|
||||
// 检查是否为401未授权
|
||||
if (response.status === 401) {
|
||||
@@ -144,7 +167,7 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (json.code !== undefined && json.code !== 0) {
|
||||
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
|
||||
const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
|
||||
const error = new Error(errorMessage);
|
||||
// @ts-expect-error augment
|
||||
@@ -324,4 +347,3 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr
|
||||
|
||||
return { abort, requestId };
|
||||
}
|
||||
|
||||
|
||||
165
services/appStoreReview.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* App Store 应用内评分服务
|
||||
*
|
||||
* 功能:
|
||||
* 1. 调用 iOS 原生模块请求应用内评分
|
||||
* 2. 管理评分请求时间间隔(至少 14 天)
|
||||
* 3. 提供便捷的业务场景触发方法
|
||||
*
|
||||
* iOS 限制说明:
|
||||
* - iOS 系统会自动限制评分请求的频率(每年最多 3 次)
|
||||
* - 本服务额外实现 14 天间隔限制,确保不会过于频繁打扰用户
|
||||
*/
|
||||
|
||||
import * as kvStore from '@/utils/kvStore';
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
// 原生模块
|
||||
const { AppStoreReviewManager } = NativeModules;
|
||||
|
||||
// 存储键
|
||||
const LAST_REVIEW_REQUEST_DATE_KEY = 'app_store_review_last_request_date';
|
||||
const MIN_DAYS_BETWEEN_REQUESTS = 14; // 最少间隔天数
|
||||
|
||||
/**
|
||||
* 检查是否可以请求评分
|
||||
* @returns Promise<boolean> 是否可以请求
|
||||
*/
|
||||
async function canRequestReview(): Promise<boolean> {
|
||||
// 只在 iOS 平台支持
|
||||
if (Platform.OS !== 'ios') {
|
||||
console.log('⚠️ App Store 评分仅支持 iOS 平台');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查原生模块是否可用
|
||||
if (!AppStoreReviewManager) {
|
||||
console.error('❌ AppStoreReviewManager 原生模块不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查系统是否支持(iOS 14.0+)
|
||||
const systemCheck = await AppStoreReviewManager.canRequestReview();
|
||||
if (!systemCheck.canRequest) {
|
||||
console.log('⚠️ 系统不支持应用内评分:', systemCheck.reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查上次请求时间
|
||||
const lastRequestDate = await kvStore.getItem(LAST_REVIEW_REQUEST_DATE_KEY);
|
||||
if (lastRequestDate) {
|
||||
const daysSinceLastRequest = Math.floor(
|
||||
(Date.now() - parseInt(lastRequestDate, 10)) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysSinceLastRequest < MIN_DAYS_BETWEEN_REQUESTS) {
|
||||
console.log(
|
||||
`⚠️ 距离上次评分请求仅 ${daysSinceLastRequest} 天,需要至少 ${MIN_DAYS_BETWEEN_REQUESTS} 天`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ 检查评分请求条件失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求应用内评分
|
||||
* @returns Promise<boolean> 是否成功发起请求
|
||||
*/
|
||||
async function requestReview(): Promise<boolean> {
|
||||
try {
|
||||
// 检查是否可以请求
|
||||
const canRequest = await canRequestReview();
|
||||
if (!canRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 调用原生模块请求评分
|
||||
const result = await AppStoreReviewManager.requestReview();
|
||||
|
||||
if (result.success) {
|
||||
// 记录本次请求时间
|
||||
await kvStore.setItem(LAST_REVIEW_REQUEST_DATE_KEY, Date.now().toString());
|
||||
console.log('✅ 应用内评分请求已发送');
|
||||
return true;
|
||||
} else {
|
||||
console.error('❌ 应用内评分请求失败:', result);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 请求应用内评分时出错:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上次请求评分的时间
|
||||
* @returns Promise<Date | null> 上次请求时间,如果从未请求过则返回 null
|
||||
*/
|
||||
async function getLastRequestDate(): Promise<Date | null> {
|
||||
try {
|
||||
const lastRequestDate = await kvStore.getItem(LAST_REVIEW_REQUEST_DATE_KEY);
|
||||
if (lastRequestDate) {
|
||||
return new Date(parseInt(lastRequestDate, 10));
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ 获取上次评分请求时间失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取距离下次可以请求评分的剩余天数
|
||||
* @returns Promise<number> 剩余天数,0 表示可以立即请求
|
||||
*/
|
||||
async function getDaysUntilNextRequest(): Promise<number> {
|
||||
try {
|
||||
const lastRequestDate = await getLastRequestDate();
|
||||
if (!lastRequestDate) {
|
||||
return 0; // 从未请求过,可以立即请求
|
||||
}
|
||||
|
||||
const daysSinceLastRequest = Math.floor(
|
||||
(Date.now() - lastRequestDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
const daysRemaining = MIN_DAYS_BETWEEN_REQUESTS - daysSinceLastRequest;
|
||||
return Math.max(0, daysRemaining);
|
||||
} catch (error) {
|
||||
console.error('❌ 计算剩余天数失败:', error);
|
||||
return MIN_DAYS_BETWEEN_REQUESTS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置评分请求记录(仅用于测试)
|
||||
* ⚠️ 警告:不应在生产环境中调用此方法
|
||||
*/
|
||||
async function resetRequestHistory(): Promise<void> {
|
||||
try {
|
||||
await kvStore.removeItem(LAST_REVIEW_REQUEST_DATE_KEY);
|
||||
console.log('✅ 评分请求历史已重置');
|
||||
} catch (error) {
|
||||
console.error('❌ 重置评分请求历史失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出服务
|
||||
export const appStoreReviewService = {
|
||||
// 核心方法
|
||||
canRequestReview,
|
||||
requestReview,
|
||||
getLastRequestDate,
|
||||
getDaysUntilNextRequest,
|
||||
resetRequestHistory,
|
||||
};
|
||||
|
||||
// 常量导出
|
||||
export { MIN_DAYS_BETWEEN_REQUESTS };
|
||||
@@ -1,12 +1,12 @@
|
||||
import { listChallenges } from '@/services/challengesApi';
|
||||
import { resyncFastingNotifications } from '@/services/fastingNotifications';
|
||||
import { store } from '@/store';
|
||||
import { selectActiveFastingPlan, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||
import { getWaterIntakeFromHealthKit } from '@/utils/health';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { log } from '@/utils/logger';
|
||||
import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getWaterGoalFromStorage } from '@/utils/userPreferences';
|
||||
import { resyncFastingNotifications } from '@/services/fastingNotifications';
|
||||
import { selectActiveFastingSchedule, selectActiveFastingPlan } from '@/store/fastingSlice';
|
||||
import { getWaterGoalFromStorage, getWaterReminderEnabled } from '@/utils/userPreferences';
|
||||
import dayjs from 'dayjs';
|
||||
import * as BackgroundTask from 'expo-background-task';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
@@ -33,6 +33,13 @@ async function executeWaterReminderTask(): Promise<void> {
|
||||
try {
|
||||
console.log('执行喝水提醒后台任务...');
|
||||
|
||||
// 检查是否开启了喝水提醒
|
||||
const isEnabled = await getWaterReminderEnabled();
|
||||
if (!isEnabled) {
|
||||
console.log('喝水提醒未开启,跳过后台任务');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前状态,添加错误处理
|
||||
let state;
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getWaterIntakeFromHealthKit } from '@/utils/health';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getWaterGoalFromStorage } from '@/utils/userPreferences';
|
||||
import { getWaterGoalFromStorage, getWaterReminderEnabled } from '@/utils/userPreferences';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -39,6 +39,13 @@ async function executeWaterReminderTask(): Promise<void> {
|
||||
try {
|
||||
console.log('执行喝水提醒后台任务...');
|
||||
|
||||
// 检查是否开启了喝水提醒
|
||||
const isEnabled = await getWaterReminderEnabled();
|
||||
if (!isEnabled) {
|
||||
console.log('喝水提醒未开启,跳过后台任务');
|
||||
return;
|
||||
}
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = store.getState();
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
66
services/medicationNotificationCleanup.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { getItemSync, setItemSync } from '@/utils/kvStore';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
const CLEANUP_KEY = 'medication_notifications_cleaned_v1';
|
||||
|
||||
/**
|
||||
* 清理所有旧的药品本地通知
|
||||
* 这个函数会在应用启动时执行一次,用于清理从本地通知迁移到服务端推送之前注册的所有药品通知
|
||||
*/
|
||||
export async function cleanupLegacyMedicationNotifications(): Promise<void> {
|
||||
try {
|
||||
// 检查是否已经执行过清理
|
||||
const alreadyCleaned = getItemSync(CLEANUP_KEY);
|
||||
if (alreadyCleaned === 'true') {
|
||||
console.log('[药品通知清理] 已执行过清理,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[药品通知清理] 开始清理旧的药品本地通知...');
|
||||
|
||||
// 获取所有已安排的通知
|
||||
const scheduledNotifications = await Notifications.getAllScheduledNotificationsAsync();
|
||||
|
||||
if (scheduledNotifications.length === 0) {
|
||||
console.log('[药品通知清理] 没有待清理的通知');
|
||||
setItemSync(CLEANUP_KEY, 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[药品通知清理] 发现 ${scheduledNotifications.length} 个已安排的通知,开始筛选药品通知...`);
|
||||
|
||||
// 筛选出药品相关的通知并取消
|
||||
let cleanedCount = 0;
|
||||
for (const notification of scheduledNotifications) {
|
||||
const data = notification.content.data;
|
||||
|
||||
// 识别药品通知的特征:
|
||||
// 1. data.type === 'medication_reminder'
|
||||
// 2. data.medicationId 存在
|
||||
// 3. identifier 包含 'medication' 关键字
|
||||
const isMedicationNotification =
|
||||
data?.type === 'medication_reminder' ||
|
||||
data?.medicationId ||
|
||||
notification.identifier?.includes('medication');
|
||||
|
||||
if (isMedicationNotification) {
|
||||
try {
|
||||
await Notifications.cancelScheduledNotificationAsync(notification.identifier);
|
||||
cleanedCount++;
|
||||
console.log(`[药品通知清理] 已取消通知: ${notification.identifier}`);
|
||||
} catch (error) {
|
||||
console.error(`[药品通知清理] 取消通知失败: ${notification.identifier}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[药品通知清理] ✅ 清理完成,共取消 ${cleanedCount} 个药品通知`);
|
||||
|
||||
// 标记清理已完成
|
||||
setItemSync(CLEANUP_KEY, 'true');
|
||||
} catch (error) {
|
||||
console.error('[药品通知清理] ❌ 清理过程出错:', error);
|
||||
// 即使出错也标记为已清理,避免每次启动都尝试
|
||||
setItemSync(CLEANUP_KEY, 'true');
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import type { Medication } from '@/types/medication';
|
||||
import { getMedicationReminderEnabled, getNotificationEnabled } from '@/utils/userPreferences';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { notificationService, NotificationTypes } from './notifications';
|
||||
|
||||
/**
|
||||
* 药品通知服务
|
||||
* 负责管理药品提醒通知的调度和取消
|
||||
*/
|
||||
export class MedicationNotificationService {
|
||||
private static instance: MedicationNotificationService;
|
||||
private notificationPrefix = 'medication_';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MedicationNotificationService {
|
||||
if (!MedicationNotificationService.instance) {
|
||||
MedicationNotificationService.instance = new MedicationNotificationService();
|
||||
}
|
||||
return MedicationNotificationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以发送药品通知
|
||||
*/
|
||||
private async canSendMedicationNotifications(): Promise<boolean> {
|
||||
try {
|
||||
// 检查总通知开关
|
||||
const notificationEnabled = await getNotificationEnabled();
|
||||
if (!notificationEnabled) {
|
||||
console.log('总通知开关已关闭,跳过药品通知');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查药品通知开关
|
||||
const medicationReminderEnabled = await getMedicationReminderEnabled();
|
||||
if (!medicationReminderEnabled) {
|
||||
console.log('药品通知开关已关闭,跳过药品通知');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查系统权限
|
||||
const permissionStatus = await notificationService.getPermissionStatus();
|
||||
if (permissionStatus !== 'granted') {
|
||||
console.log('系统通知权限未授予,跳过药品通知');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('检查药品通知权限失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为药品安排通知
|
||||
*/
|
||||
async scheduleMedicationNotifications(medication: Medication): Promise<void> {
|
||||
try {
|
||||
const canSend = await this.canSendMedicationNotifications();
|
||||
if (!canSend) {
|
||||
console.log('药品通知权限不足,跳过安排通知');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先取消该药品的现有通知
|
||||
await this.cancelMedicationNotifications(medication.id);
|
||||
|
||||
// 为每个用药时间安排通知
|
||||
for (const time of medication.medicationTimes) {
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
|
||||
// 创建通知内容
|
||||
const notificationContent = {
|
||||
title: '用药提醒',
|
||||
body: `该服用 ${medication.name} 了 (${medication.dosageValue}${medication.dosageUnit})`,
|
||||
data: {
|
||||
type: NotificationTypes.MEDICATION_REMINDER,
|
||||
medicationId: medication.id,
|
||||
medicationName: medication.name,
|
||||
dosage: `${medication.dosageValue}${medication.dosageUnit}`,
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high' as const,
|
||||
};
|
||||
|
||||
// 安排每日重复通知
|
||||
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
|
||||
notificationContent,
|
||||
{
|
||||
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
||||
hour,
|
||||
minute,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`已为药品 ${medication.name} 安排通知,时间: ${time},通知ID: ${notificationId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('安排药品通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消药品的所有通知
|
||||
*/
|
||||
async cancelMedicationNotifications(medicationId: string): Promise<void> {
|
||||
try {
|
||||
// 获取所有已安排的通知
|
||||
const allNotifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
// 过滤出该药品的通知并取消
|
||||
for (const notification of allNotifications) {
|
||||
const data = notification.content.data as any;
|
||||
if (data?.type === NotificationTypes.MEDICATION_REMINDER &&
|
||||
data?.medicationId === medicationId) {
|
||||
await notificationService.cancelNotification(notification.identifier);
|
||||
console.log(`已取消药品通知,ID: ${notification.identifier}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('取消药品通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新安排所有激活药品的通知
|
||||
*/
|
||||
async rescheduleAllMedicationNotifications(medications: Medication[]): Promise<void> {
|
||||
try {
|
||||
// 先取消所有药品通知
|
||||
for (const medication of medications) {
|
||||
await this.cancelMedicationNotifications(medication.id);
|
||||
}
|
||||
|
||||
// 重新安排激活药品的通知
|
||||
const activeMedications = medications.filter(m => m.isActive);
|
||||
for (const medication of activeMedications) {
|
||||
await this.scheduleMedicationNotifications(medication);
|
||||
}
|
||||
|
||||
console.log(`已重新安排 ${activeMedications.length} 个激活药品的通知`);
|
||||
} catch (error) {
|
||||
console.error('重新安排药品通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送立即的药品通知(用于测试)
|
||||
*/
|
||||
async sendTestMedicationNotification(medication: Medication): Promise<string> {
|
||||
try {
|
||||
const canSend = await this.canSendMedicationNotifications();
|
||||
if (!canSend) {
|
||||
throw new Error('药品通知权限不足');
|
||||
}
|
||||
|
||||
return await notificationService.sendImmediateNotification({
|
||||
title: '用药提醒测试',
|
||||
body: `这是 ${medication.name} 的测试通知 (${medication.dosageValue}${medication.dosageUnit})`,
|
||||
data: {
|
||||
type: NotificationTypes.MEDICATION_REMINDER,
|
||||
medicationId: medication.id,
|
||||
medicationName: medication.name,
|
||||
dosage: `${medication.dosageValue}${medication.dosageUnit}`,
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送测试药品通知失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已安排的药品通知
|
||||
*/
|
||||
async getMedicationNotifications(): Promise<Notifications.NotificationRequest[]> {
|
||||
try {
|
||||
const allNotifications = await notificationService.getAllScheduledNotifications();
|
||||
|
||||
// 过滤出药品相关的通知
|
||||
return allNotifications.filter(notification =>
|
||||
notification.content.data?.type === NotificationTypes.MEDICATION_REMINDER
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('获取药品通知失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const medicationNotificationService = MedicationNotificationService.getInstance();
|
||||
@@ -5,7 +5,9 @@
|
||||
import type {
|
||||
DailyMedicationStats,
|
||||
Medication,
|
||||
MedicationAiAnalysisV2,
|
||||
MedicationForm,
|
||||
MedicationRecognitionTask,
|
||||
MedicationRecord,
|
||||
MedicationStatus,
|
||||
RepeatPattern,
|
||||
@@ -27,6 +29,7 @@ export interface CreateMedicationDto {
|
||||
medicationTimes: string[];
|
||||
startDate: string;
|
||||
endDate?: string | null;
|
||||
expiryDate?: string | null;
|
||||
repeatPattern?: RepeatPattern;
|
||||
note?: string;
|
||||
}
|
||||
@@ -329,3 +332,53 @@ export async function analyzeMedicationStream(
|
||||
{ timeoutMs: 120000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取药品 AI 分析 V2 结构化报告
|
||||
* @param medicationId 药品 ID
|
||||
* @returns 结构化 AI 分析结果
|
||||
*/
|
||||
export async function analyzeMedicationV2(
|
||||
medicationId: string
|
||||
): Promise<MedicationAiAnalysisV2> {
|
||||
return api.post<MedicationAiAnalysisV2>(
|
||||
`/api/medications/${medicationId}/ai-analysis/v2`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== AI 药品识别任务 ====================
|
||||
|
||||
export interface CreateMedicationRecognitionDto {
|
||||
frontImageUrl: string;
|
||||
sideImageUrl: string;
|
||||
auxiliaryImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmMedicationRecognitionDto {
|
||||
name?: string;
|
||||
timesPerDay?: number;
|
||||
medicationTimes?: string[];
|
||||
startDate?: string;
|
||||
endDate?: string | null;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export const createMedicationRecognitionTask = async (
|
||||
dto: CreateMedicationRecognitionDto
|
||||
): Promise<{ taskId: string; status: MedicationRecognitionTask['status'] }> => {
|
||||
return api.post('/medications/ai-recognize', dto);
|
||||
};
|
||||
|
||||
export const getMedicationRecognitionStatus = async (
|
||||
taskId: string
|
||||
): Promise<MedicationRecognitionTask> => {
|
||||
return api.get(`/medications/ai-recognize/${taskId}/status`);
|
||||
};
|
||||
|
||||
export const confirmMedicationRecognition = async (
|
||||
taskId: string,
|
||||
payload?: ConfirmMedicationRecognitionDto
|
||||
): Promise<Medication> => {
|
||||
return api.post(`/medications/ai-recognize/${taskId}/confirm`, payload ?? {});
|
||||
};
|
||||
|
||||
@@ -238,11 +238,6 @@ export class NotificationService {
|
||||
console.log('用户点击了 HRV 压力通知', data);
|
||||
const targetUrl = (data?.url as string) || '/(tabs)/statistics';
|
||||
router.push(targetUrl as any);
|
||||
} else if (data?.type === NotificationTypes.MEDICATION_REMINDER) {
|
||||
// 处理药品提醒通知
|
||||
console.log('用户点击了药品提醒通知', data);
|
||||
// 跳转到药品页面
|
||||
router.push('/(tabs)/medications' as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +579,6 @@ export const NotificationTypes = {
|
||||
WORKOUT_COMPLETION: 'workout_completion',
|
||||
FASTING_START: 'fasting_start',
|
||||
FASTING_END: 'fasting_end',
|
||||
MEDICATION_REMINDER: 'medication_reminder',
|
||||
HRV_STRESS_ALERT: 'hrv_stress_alert',
|
||||
} as const;
|
||||
|
||||
@@ -623,21 +617,3 @@ export const sendMoodCheckinReminder = (title: string, body: string, date?: Date
|
||||
}
|
||||
};
|
||||
|
||||
export const sendMedicationReminder = (title: string, body: string, medicationId?: string, date?: Date) => {
|
||||
const notification: NotificationData = {
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
type: NotificationTypes.MEDICATION_REMINDER,
|
||||
medicationId: medicationId || ''
|
||||
},
|
||||
sound: true,
|
||||
priority: 'high',
|
||||
};
|
||||
|
||||
if (date) {
|
||||
return notificationService.scheduleNotificationAtDate(notification, date);
|
||||
} else {
|
||||
return notificationService.sendImmediateNotification(notification);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAuthToken } from '@/services/api';
|
||||
import { logger } from '@/utils/logger';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
@@ -90,9 +91,6 @@ export class PushNotificationManager {
|
||||
// 检查是否需要注册令牌
|
||||
await this.checkAndRegisterToken(token);
|
||||
|
||||
// 设置令牌刷新监听器
|
||||
this.setupTokenRefreshListener();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('推送通知管理器初始化成功');
|
||||
return true;
|
||||
@@ -170,8 +168,12 @@ export class PushNotificationManager {
|
||||
if (!isRegistered || storedToken !== token) {
|
||||
await this.registerDeviceToken(token);
|
||||
} else {
|
||||
// 令牌已注册且未变化,更新用户ID绑定关系
|
||||
await this.updateTokenUserId(token);
|
||||
// 令牌已注册且未变化
|
||||
// 只有在用户已登录的情况下才更新用户ID绑定关系
|
||||
const authToken = await getAuthToken();
|
||||
if (authToken) {
|
||||
await this.updateTokenUserId(token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查和注册设备令牌失败:', error);
|
||||
@@ -313,16 +315,6 @@ export class PushNotificationManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置令牌刷新监听器
|
||||
*/
|
||||
private setupTokenRefreshListener(): void {
|
||||
// 监听令牌变化(iOS上通常不会频繁变化)
|
||||
Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
console.log('收到推送通知响应:', response);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前设备令牌
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BadgeDto, BadgeRarity } from '@/services/badges';
|
||||
import { getAvailableBadges } from '@/services/badges';
|
||||
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
|
||||
import dayjs from 'dayjs';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
import type { RootState } from './index';
|
||||
|
||||
@@ -19,11 +20,20 @@ const initialState: BadgesState = {
|
||||
lastFetched: null,
|
||||
};
|
||||
|
||||
// 创建节流版本的 fetchAvailableBadges 内部函数
|
||||
const throttledFetchAvailableBadges = throttle(
|
||||
async (): Promise<BadgeDto[]> => {
|
||||
return await getAvailableBadges();
|
||||
},
|
||||
2000, // 2秒节流
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
|
||||
export const fetchAvailableBadges = createAsyncThunk<BadgeDto[], void, { rejectValue: string }>(
|
||||
'badges/fetchAvailable',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
return await getAvailableBadges();
|
||||
return await throttledFetchAvailableBadges();
|
||||
} catch (error: any) {
|
||||
const message = error?.message ?? '获取勋章列表失败';
|
||||
return rejectWithValue(message);
|
||||
|
||||
@@ -1,12 +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,
|
||||
@@ -20,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;
|
||||
};
|
||||
@@ -37,7 +46,7 @@ type ChallengeRankingList = {
|
||||
|
||||
type ChallengesState = {
|
||||
entities: Record<string, ChallengeEntity>;
|
||||
order: string[];
|
||||
orderedIds: string[];
|
||||
listStatus: AsyncStatus;
|
||||
listError?: string;
|
||||
detailStatus: Record<string, AsyncStatus>;
|
||||
@@ -52,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: {},
|
||||
@@ -71,6 +84,10 @@ const initialState: ChallengesState = {
|
||||
rankingStatus: {},
|
||||
rankingLoadMoreStatus: {},
|
||||
rankingError: {},
|
||||
createStatus: 'idle',
|
||||
createError: undefined,
|
||||
joinByCodeStatus: 'idle',
|
||||
joinByCodeError: undefined,
|
||||
};
|
||||
|
||||
const toErrorMessage = (error: unknown): string => {
|
||||
@@ -113,6 +130,15 @@ export const joinChallenge = createAsyncThunk<{ id: string; progress: ChallengeP
|
||||
async (id, { rejectWithValue }) => {
|
||||
try {
|
||||
const progress = await joinChallengeApi(id);
|
||||
|
||||
// 用户成功加入挑战后,尝试请求应用评分
|
||||
// 使用 setTimeout 延迟执行,避免阻塞主流程
|
||||
setTimeout(() => {
|
||||
appStoreReviewService.requestReview().catch((error) => {
|
||||
console.error('应用评分请求失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return { id, progress };
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error));
|
||||
@@ -158,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) => {
|
||||
@@ -171,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];
|
||||
@@ -194,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';
|
||||
@@ -210,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;
|
||||
@@ -323,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;
|
||||
@@ -345,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;
|
||||
@@ -374,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;
|
||||
@@ -382,7 +504,7 @@ export type ChallengeCardViewModel = {
|
||||
participantsLabel: string;
|
||||
status: ChallengeStatus;
|
||||
isJoined: boolean;
|
||||
endAt?: string;
|
||||
endAt?: string | number;
|
||||
periodLabel?: string;
|
||||
durationLabel: string;
|
||||
requirementLabel: string;
|
||||
@@ -391,27 +513,109 @@ export type ChallengeCardViewModel = {
|
||||
ctaLabel: string;
|
||||
progress?: ChallengeProgress;
|
||||
avatars: string[];
|
||||
source?: ChallengeSource;
|
||||
shareCode?: string | null;
|
||||
challengeState?: ChallengeState;
|
||||
progressUnit?: string;
|
||||
targetValue?: number;
|
||||
isCreator?: boolean;
|
||||
};
|
||||
|
||||
export const selectChallengeCards = createSelector([selectChallengeList], (challenges) =>
|
||||
challenges.map<ChallengeCardViewModel>((challenge) => ({
|
||||
id: challenge.id,
|
||||
title: challenge.title,
|
||||
image: challenge.image,
|
||||
dateRange: buildDateRangeLabel(challenge),
|
||||
participantsLabel: `${formatNumberWithSeparator(challenge.participantsCount)} 人参与`,
|
||||
status: challenge.status,
|
||||
isJoined: challenge.isJoined,
|
||||
endAt: challenge.endAt,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
highlightSubtitle: challenge.highlightSubtitle,
|
||||
ctaLabel: challenge.ctaLabel,
|
||||
progress: challenge.progress,
|
||||
avatars: [],
|
||||
}))
|
||||
challenges.map<ChallengeCardViewModel>((challenge) => {
|
||||
const participants =
|
||||
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
||||
return {
|
||||
id: challenge.id,
|
||||
title: challenge.title,
|
||||
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
||||
dateRange: buildDateRangeLabel(challenge),
|
||||
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
||||
status: deriveStatus(challenge),
|
||||
isJoined: challenge.isJoined,
|
||||
endAt: challenge.endAt,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
highlightSubtitle: challenge.highlightSubtitle,
|
||||
ctaLabel: challenge.ctaLabel,
|
||||
progress: challenge.progress,
|
||||
avatars: [],
|
||||
source: challenge.source,
|
||||
shareCode: challenge.shareCode ?? null,
|
||||
challengeState: challenge.challengeState,
|
||||
progressUnit: challenge.progressUnit,
|
||||
targetValue: challenge.targetValue,
|
||||
isCreator: challenge.isCreator,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const selectCustomChallengeCards = createSelector(
|
||||
[selectCustomChallengeList],
|
||||
(challenges) =>
|
||||
challenges.map<ChallengeCardViewModel>((challenge) => {
|
||||
const participants =
|
||||
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
||||
return {
|
||||
id: challenge.id,
|
||||
title: challenge.title,
|
||||
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
||||
dateRange: buildDateRangeLabel(challenge),
|
||||
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
||||
status: deriveStatus(challenge),
|
||||
isJoined: challenge.isJoined,
|
||||
endAt: challenge.endAt,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
highlightSubtitle: challenge.highlightSubtitle,
|
||||
ctaLabel: challenge.ctaLabel,
|
||||
progress: challenge.progress,
|
||||
avatars: [],
|
||||
source: challenge.source ?? ChallengeSource.CUSTOM,
|
||||
shareCode: challenge.shareCode ?? null,
|
||||
challengeState: challenge.challengeState,
|
||||
progressUnit: challenge.progressUnit,
|
||||
targetValue: challenge.targetValue,
|
||||
isCreator: challenge.isCreator,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const selectOfficialChallengeCards = createSelector(
|
||||
[selectOfficialChallengeList],
|
||||
(challenges) =>
|
||||
challenges.map<ChallengeCardViewModel>((challenge) => {
|
||||
const participants =
|
||||
typeof challenge.participantsCount === 'number' ? challenge.participantsCount : 0;
|
||||
return {
|
||||
id: challenge.id,
|
||||
title: challenge.title,
|
||||
image: challenge.image ?? FALLBACK_CHALLENGE_IMAGE,
|
||||
dateRange: buildDateRangeLabel(challenge),
|
||||
participantsLabel: `${formatNumberWithSeparator(participants)} 人参与`,
|
||||
status: deriveStatus(challenge),
|
||||
isJoined: challenge.isJoined,
|
||||
endAt: challenge.endAt,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
highlightSubtitle: challenge.highlightSubtitle,
|
||||
ctaLabel: challenge.ctaLabel,
|
||||
progress: challenge.progress,
|
||||
avatars: [],
|
||||
source: challenge.source ?? ChallengeSource.SYSTEM,
|
||||
shareCode: challenge.shareCode ?? null,
|
||||
challengeState: challenge.challengeState,
|
||||
progressUnit: challenge.progressUnit,
|
||||
targetValue: challenge.targetValue,
|
||||
isCreator: challenge.isCreator,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
export const selectChallengeById = (id: string) =>
|
||||
@@ -452,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
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import membershipReducer from './membershipSlice';
|
||||
import moodReducer from './moodSlice';
|
||||
import nutritionReducer from './nutritionSlice';
|
||||
import scheduleExerciseReducer from './scheduleExerciseSlice';
|
||||
import tabBarConfigReducer from './tabBarConfigSlice';
|
||||
import trainingPlanReducer from './trainingPlanSlice';
|
||||
import userReducer from './userSlice';
|
||||
import waterReducer from './waterSlice';
|
||||
@@ -113,6 +114,7 @@ export const store = configureStore({
|
||||
fasting: fastingReducer,
|
||||
medications: medicationsReducer,
|
||||
badges: badgesReducer,
|
||||
tabBarConfig: tabBarConfigReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 药物管理 Redux Slice
|
||||
*/
|
||||
|
||||
import { appStoreReviewService } from '@/services/appStoreReview';
|
||||
import * as medicationsApi from '@/services/medications';
|
||||
import type {
|
||||
DailyMedicationStats,
|
||||
@@ -10,7 +11,7 @@ import type {
|
||||
MedicationStatus,
|
||||
} from '@/types/medication';
|
||||
import { convertMedicationDataToWidget, syncMedicationDataToWidget } from '@/utils/widgetDataSync';
|
||||
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import dayjs from 'dayjs';
|
||||
import type { RootState } from './index';
|
||||
|
||||
@@ -605,6 +606,13 @@ const medicationsSlice = createSlice({
|
||||
console.error('Failed to sync medication data to widget:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 服药成功后请求应用评分(延迟1秒,避免阻塞主流程)
|
||||
setTimeout(() => {
|
||||
appStoreReviewService.requestReview().catch((error) => {
|
||||
console.error('应用评分请求失败:', error);
|
||||
});
|
||||
}, 1000);
|
||||
})
|
||||
.addCase(takeMedicationAction.rejected, (state, action) => {
|
||||
state.loading.takeMedication = false;
|
||||
@@ -687,6 +695,9 @@ export const {
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
// 空数组常量,避免每次都创建新数组
|
||||
const EMPTY_RECORDS_ARRAY: MedicationRecord[] = [];
|
||||
|
||||
export const selectMedicationsState = (state: RootState) => state.medications;
|
||||
export const selectMedications = (state: RootState) => state.medications.medications;
|
||||
export const selectActiveMedications = (state: RootState) =>
|
||||
@@ -700,7 +711,7 @@ export const selectOverallStats = (state: RootState) => state.medications.overal
|
||||
* 获取指定日期的服药记录
|
||||
*/
|
||||
export const selectMedicationRecordsByDate = (date: string) => (state: RootState) => {
|
||||
return state.medications.medicationRecords[date] || [];
|
||||
return state.medications.medicationRecords[date] || EMPTY_RECORDS_ARRAY;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -708,7 +719,7 @@ export const selectMedicationRecordsByDate = (date: string) => (state: RootState
|
||||
*/
|
||||
export const selectSelectedDateMedicationRecords = (state: RootState) => {
|
||||
const selectedDate = state.medications.selectedDate;
|
||||
return state.medications.medicationRecords[selectedDate] || [];
|
||||
return state.medications.medicationRecords[selectedDate] || EMPTY_RECORDS_ARRAY;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -727,72 +738,76 @@ export const selectSelectedDateStats = (state: RootState) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定日期的展示项列表(用于UI渲染)
|
||||
* 获取指定日期的展示项列表(用于UI渲染)- 使用 createSelector 进行 memoization
|
||||
* 将药物记录和药物信息合并为展示项
|
||||
* 排序规则:优先显示未服用的药品(upcoming、missed),然后是已服用的药品(taken、skipped)
|
||||
*/
|
||||
export const selectMedicationDisplayItemsByDate = (date: string) => (state: RootState) => {
|
||||
const records = state.medications.medicationRecords[date] || [];
|
||||
const medications = state.medications.medications;
|
||||
export const selectMedicationDisplayItemsByDate = (date: string) =>
|
||||
createSelector(
|
||||
[
|
||||
(state: RootState) => state.medications.medicationRecords[date] || EMPTY_RECORDS_ARRAY,
|
||||
(state: RootState) => state.medications.medications,
|
||||
],
|
||||
(records, medications) => {
|
||||
// 创建药物ID到药物的映射
|
||||
const medicationMap = new Map<string, Medication>();
|
||||
medications.forEach((med) => medicationMap.set(med.id, med));
|
||||
|
||||
// 创建药物ID到药物的映射
|
||||
const medicationMap = new Map<string, Medication>();
|
||||
medications.forEach((med) => medicationMap.set(med.id, med));
|
||||
// 转换为展示项
|
||||
const displayItems = records
|
||||
.map((record) => {
|
||||
const medication = medicationMap.get(record.medicationId);
|
||||
if (!medication) return null;
|
||||
|
||||
// 转换为展示项
|
||||
const displayItems = records
|
||||
.map((record) => {
|
||||
const medication = medicationMap.get(record.medicationId);
|
||||
if (!medication) return null;
|
||||
// 格式化剂量
|
||||
const dosage = `${medication.dosageValue} ${medication.dosageUnit}`;
|
||||
|
||||
// 格式化剂量
|
||||
const dosage = `${medication.dosageValue} ${medication.dosageUnit}`;
|
||||
// 提取并格式化为当地时间(HH:mm格式)
|
||||
// 服务端返回的是UTC时间,需要转换为用户本地时间显示
|
||||
const localTime = dayjs(record.scheduledTime).format('HH:mm');
|
||||
const scheduledTime = localTime || '00:00';
|
||||
|
||||
// 提取并格式化为当地时间(HH:mm格式)
|
||||
// 服务端返回的是UTC时间,需要转换为用户本地时间显示
|
||||
const localTime = dayjs(record.scheduledTime).format('HH:mm');
|
||||
const scheduledTime = localTime || '00:00';
|
||||
// 频率描述
|
||||
const frequency = medication.repeatPattern === 'daily' ? '每日' : '自定义';
|
||||
|
||||
// 频率描述
|
||||
const frequency = medication.repeatPattern === 'daily' ? '每日' : '自定义';
|
||||
return {
|
||||
id: record.id,
|
||||
name: medication.name,
|
||||
dosage,
|
||||
scheduledTime,
|
||||
frequency,
|
||||
status: record.status,
|
||||
recordId: record.id,
|
||||
medicationId: medication.id,
|
||||
image: medication.photoUrl ? { uri: medication.photoUrl } : undefined
|
||||
} as import('@/types/medication').MedicationDisplayItem;
|
||||
})
|
||||
.filter((item): item is import('@/types/medication').MedicationDisplayItem => item !== null);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: medication.name,
|
||||
dosage,
|
||||
scheduledTime,
|
||||
frequency,
|
||||
status: record.status,
|
||||
recordId: record.id,
|
||||
medicationId: medication.id,
|
||||
image: medication.photoUrl ? { uri: medication.photoUrl } : undefined
|
||||
} as import('@/types/medication').MedicationDisplayItem;
|
||||
})
|
||||
.filter((item): item is import('@/types/medication').MedicationDisplayItem => item !== null);
|
||||
// 排序:未服用的药品(upcoming、missed)优先,已服用的药品(taken、skipped)其次
|
||||
// 在同一组内,按计划时间升序排列
|
||||
return displayItems.sort((a, b) => {
|
||||
// 定义状态优先级:数值越小优先级越高
|
||||
const statusPriority: Record<MedicationStatus, number> = {
|
||||
'missed': 1, // 已错过 - 最高优先级
|
||||
'upcoming': 2, // 待服用
|
||||
'taken': 3, // 已服用
|
||||
'skipped': 4, // 已跳过
|
||||
};
|
||||
|
||||
// 排序:未服用的药品(upcoming、missed)优先,已服用的药品(taken、skipped)其次
|
||||
// 在同一组内,按计划时间升序排列
|
||||
return displayItems.sort((a, b) => {
|
||||
// 定义状态优先级:数值越小优先级越高
|
||||
const statusPriority: Record<MedicationStatus, number> = {
|
||||
'missed': 1, // 已错过 - 最高优先级
|
||||
'upcoming': 2, // 待服用
|
||||
'taken': 3, // 已服用
|
||||
'skipped': 4, // 已跳过
|
||||
};
|
||||
const priorityA = statusPriority[a.status];
|
||||
const priorityB = statusPriority[b.status];
|
||||
|
||||
const priorityA = statusPriority[a.status];
|
||||
const priorityB = statusPriority[b.status];
|
||||
// 首先按状态优先级排序
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
|
||||
// 首先按状态优先级排序
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
// 状态相同时,按计划时间升序排列
|
||||
return a.scheduledTime.localeCompare(b.scheduledTime);
|
||||
});
|
||||
}
|
||||
|
||||
// 状态相同时,按计划时间升序排列
|
||||
return a.scheduledTime.localeCompare(b.scheduledTime);
|
||||
});
|
||||
};
|
||||
);
|
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
|
||||
208
store/tabBarConfigSlice.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { RootState } from './index';
|
||||
|
||||
// Tab 配置接口
|
||||
export interface TabConfig {
|
||||
id: string; // tab 标识符
|
||||
icon: string; // SF Symbol 图标名
|
||||
titleKey: string; // i18n 翻译 key
|
||||
enabled: boolean; // 是否启用
|
||||
canBeDisabled: boolean; // 是否可以被禁用
|
||||
order: number; // 显示顺序
|
||||
}
|
||||
|
||||
// State 接口
|
||||
interface TabBarConfigState {
|
||||
configs: TabConfig[];
|
||||
isInitialized: boolean;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
export const DEFAULT_TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
id: 'statistics',
|
||||
icon: 'chart.pie.fill',
|
||||
titleKey: 'statistics.tabs.health',
|
||||
enabled: true,
|
||||
canBeDisabled: false,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'medications',
|
||||
icon: 'pills.fill',
|
||||
titleKey: 'statistics.tabs.medications',
|
||||
enabled: true,
|
||||
canBeDisabled: false,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'fasting',
|
||||
icon: 'timer',
|
||||
titleKey: 'statistics.tabs.fasting',
|
||||
enabled: true,
|
||||
canBeDisabled: true, // 只有断食可以被关闭
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'challenges',
|
||||
icon: 'trophy.fill',
|
||||
titleKey: 'statistics.tabs.challenges',
|
||||
enabled: true,
|
||||
canBeDisabled: false,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: 'personal',
|
||||
icon: 'person.fill',
|
||||
titleKey: 'statistics.tabs.personal',
|
||||
enabled: true,
|
||||
canBeDisabled: false,
|
||||
order: 5,
|
||||
},
|
||||
];
|
||||
|
||||
// AsyncStorage key
|
||||
const STORAGE_KEY = 'tab_bar_config';
|
||||
|
||||
// 初始状态
|
||||
const initialState: TabBarConfigState = {
|
||||
configs: DEFAULT_TAB_CONFIGS,
|
||||
isInitialized: false,
|
||||
};
|
||||
|
||||
const tabBarConfigSlice = createSlice({
|
||||
name: 'tabBarConfig',
|
||||
initialState,
|
||||
reducers: {
|
||||
// 设置配置(用于从 AsyncStorage 恢复)
|
||||
setConfigs: (state, action: PayloadAction<TabConfig[]>) => {
|
||||
state.configs = action.payload;
|
||||
state.isInitialized = true;
|
||||
},
|
||||
|
||||
// 切换 tab 启用状态
|
||||
toggleTabEnabled: (state, action: PayloadAction<string>) => {
|
||||
const tabId = action.payload;
|
||||
const config = state.configs.find(c => c.id === tabId);
|
||||
|
||||
if (config && config.canBeDisabled) {
|
||||
config.enabled = !config.enabled;
|
||||
// 自动持久化到 AsyncStorage
|
||||
saveConfigsToStorage(state.configs);
|
||||
}
|
||||
},
|
||||
|
||||
// 更新 tab 顺序(拖拽后)
|
||||
reorderTabs: (state, action: PayloadAction<TabConfig[]>) => {
|
||||
// 更新顺序,同时保持其他属性不变
|
||||
const newConfigs = action.payload.map((config, index) => ({
|
||||
...config,
|
||||
order: index + 1,
|
||||
}));
|
||||
state.configs = newConfigs;
|
||||
// 自动持久化到 AsyncStorage
|
||||
saveConfigsToStorage(newConfigs);
|
||||
},
|
||||
|
||||
// 恢复默认配置
|
||||
resetToDefault: (state) => {
|
||||
state.configs = DEFAULT_TAB_CONFIGS;
|
||||
// 持久化到 AsyncStorage
|
||||
saveConfigsToStorage(DEFAULT_TAB_CONFIGS);
|
||||
},
|
||||
|
||||
// 标记已初始化
|
||||
markInitialized: (state) => {
|
||||
state.isInitialized = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 持久化配置到 AsyncStorage
|
||||
const saveConfigsToStorage = async (configs: TabConfig[]) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(configs));
|
||||
logger.info('底部栏配置已保存');
|
||||
} catch (error) {
|
||||
logger.error('保存底部栏配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从 AsyncStorage 加载配置
|
||||
export const loadTabBarConfigs = () => async (dispatch: any) => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (stored) {
|
||||
const configs = JSON.parse(stored) as TabConfig[];
|
||||
|
||||
// 验证配置有效性
|
||||
if (Array.isArray(configs) && configs.length > 0) {
|
||||
// 合并默认配置,确保新增的 tab 也能显示
|
||||
const mergedConfigs = mergeWithDefaults(configs);
|
||||
dispatch(setConfigs(mergedConfigs));
|
||||
logger.info('底部栏配置已加载');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有存储或无效,使用默认配置
|
||||
dispatch(setConfigs(DEFAULT_TAB_CONFIGS));
|
||||
dispatch(markInitialized());
|
||||
} catch (error) {
|
||||
logger.error('加载底部栏配置失败:', error);
|
||||
// 出错时使用默认配置
|
||||
dispatch(setConfigs(DEFAULT_TAB_CONFIGS));
|
||||
dispatch(markInitialized());
|
||||
}
|
||||
};
|
||||
|
||||
// 合并存储的配置和默认配置
|
||||
const mergeWithDefaults = (storedConfigs: TabConfig[]): TabConfig[] => {
|
||||
const merged = [...storedConfigs];
|
||||
|
||||
// 检查是否有新增的默认 tab
|
||||
DEFAULT_TAB_CONFIGS.forEach(defaultConfig => {
|
||||
const exists = merged.find(c => c.id === defaultConfig.id);
|
||||
if (!exists) {
|
||||
// 新增的 tab,添加到末尾
|
||||
merged.push({
|
||||
...defaultConfig,
|
||||
order: merged.length + 1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 按 order 排序
|
||||
return merged.sort((a, b) => a.order - b.order);
|
||||
};
|
||||
|
||||
// Actions
|
||||
export const {
|
||||
setConfigs,
|
||||
toggleTabEnabled,
|
||||
reorderTabs,
|
||||
resetToDefault,
|
||||
markInitialized,
|
||||
} = tabBarConfigSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectTabBarConfigs = (state: RootState) => state.tabBarConfig.configs;
|
||||
|
||||
// ✅ 使用 createSelector 进行记忆化,避免不必要的重渲染
|
||||
export const selectEnabledTabs = createSelector(
|
||||
[selectTabBarConfigs],
|
||||
(configs) => configs
|
||||
.filter(config => config.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
);
|
||||
|
||||
export const selectIsInitialized = (state: RootState) => state.tabBarConfig.isInitialized;
|
||||
|
||||
// 按 id 获取配置
|
||||
export const selectTabConfigById = (tabId: string) => (state: RootState) =>
|
||||
state.tabBarConfig.configs.find(config => config.id === tabId);
|
||||
|
||||
export default tabBarConfigSlice.reducer;
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface Medication {
|
||||
medicationTimes: string[]; // 服药时间列表 ['08:00', '20:00']
|
||||
startDate: string; // 开始日期 ISO
|
||||
endDate?: string | null; // 结束日期 ISO(可选)
|
||||
expiryDate?: string | null; // 药品有效期 ISO(可选)
|
||||
repeatPattern: RepeatPattern; // 重复模式
|
||||
note?: string; // 备注
|
||||
aiAnalysis?: string; // AI 分析结果(Markdown 格式)
|
||||
@@ -92,3 +93,61 @@ export interface MedicationDisplayItem {
|
||||
recordId?: string; // 服药记录ID(用于更新状态)
|
||||
medicationId: string; // 药物ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 药品 AI 分析 V2 结构化数据
|
||||
*/
|
||||
export interface MedicationAiAnalysisV2 {
|
||||
suitableFor: string[]; // 适合人群
|
||||
unsuitableFor: string[]; // 不适合人群/慎用
|
||||
mainIngredients: string[]; // 主要成分
|
||||
mainUsage: string; // 主要用途/功效
|
||||
sideEffects: string[]; // 常见副作用
|
||||
storageAdvice: string[]; // 储存建议
|
||||
healthAdvice: string[]; // 健康建议/使用建议
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 识别结果结构化数据
|
||||
*/
|
||||
export interface MedicationAiRecognitionResult {
|
||||
name: string;
|
||||
photoUrl?: string;
|
||||
form?: MedicationForm;
|
||||
dosageValue?: number;
|
||||
dosageUnit?: string;
|
||||
timesPerDay?: number;
|
||||
medicationTimes?: string[];
|
||||
startDate?: string;
|
||||
endDate?: string | null;
|
||||
expiryDate?: string | null;
|
||||
note?: string;
|
||||
suitableFor?: string[];
|
||||
unsuitableFor?: string[];
|
||||
mainIngredients?: string[];
|
||||
mainUsage?: string;
|
||||
sideEffects?: string[];
|
||||
storageAdvice?: string[];
|
||||
healthAdvice?: string[];
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export type MedicationRecognitionStatus =
|
||||
| 'pending'
|
||||
| 'analyzing_product'
|
||||
| 'analyzing_suitability'
|
||||
| 'analyzing_ingredients'
|
||||
| 'analyzing_effects'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface MedicationRecognitionTask {
|
||||
taskId: string;
|
||||
status: MedicationRecognitionStatus;
|
||||
currentStep?: string;
|
||||
progress?: number;
|
||||
result?: MedicationAiRecognitionResult;
|
||||
errorMessage?: string; // 识别失败时的错误信息
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { NotificationData, NotificationTypes, notificationService } from '../services/notifications';
|
||||
import { getNotificationEnabled } from './userPreferences';
|
||||
import { getNotificationEnabled, getWaterReminderEnabled } from './userPreferences';
|
||||
|
||||
/**
|
||||
* 构建 coach 页面的深度链接
|
||||
@@ -433,6 +433,13 @@ export class WaterNotificationHelpers {
|
||||
currentHour: number = new Date().getHours()
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// 首先检查用户是否启用了喝水提醒
|
||||
const isWaterReminderEnabled = await getWaterReminderEnabled();
|
||||
if (!isWaterReminderEnabled) {
|
||||
console.log('用户未启用喝水提醒,跳过通知检查');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查时间限制:早上9点以前和晚上9点以后不通知
|
||||
if (currentHour < 9 || currentHour >= 23) {
|
||||
console.log(`当前时间${currentHour}点,不在通知时间范围内(9:00-21:00),跳过喝水提醒`);
|
||||
@@ -546,6 +553,15 @@ export class WaterNotificationHelpers {
|
||||
*/
|
||||
static async scheduleRegularWaterReminders(userName: string): Promise<string[]> {
|
||||
try {
|
||||
// 首先检查用户是否启用了喝水提醒
|
||||
const isWaterReminderEnabled = await getWaterReminderEnabled();
|
||||
if (!isWaterReminderEnabled) {
|
||||
console.log('用户未启用喝水提醒,不安排定期提醒');
|
||||
// 确保取消任何可能存在的旧提醒
|
||||
await this.cancelAllWaterReminders();
|
||||
return [];
|
||||
}
|
||||
|
||||
const notificationIds: string[] = [];
|
||||
|
||||
// 检查是否已经存在定期喝水提醒
|
||||
|
||||
@@ -12,6 +12,8 @@ const PREFERENCES_KEYS = {
|
||||
WATER_REMINDER_END_TIME: 'user_preference_water_reminder_end_time',
|
||||
WATER_REMINDER_INTERVAL: 'user_preference_water_reminder_interval',
|
||||
MEDICATION_REMINDER_ENABLED: 'user_preference_medication_reminder_enabled',
|
||||
NUTRITION_REMINDER_ENABLED: 'user_preference_nutrition_reminder_enabled',
|
||||
MOOD_REMINDER_ENABLED: 'user_preference_mood_reminder_enabled',
|
||||
} as const;
|
||||
|
||||
// 用户偏好设置接口
|
||||
@@ -26,6 +28,8 @@ export interface UserPreferences {
|
||||
waterReminderEndTime: string; // 格式: "22:00"
|
||||
waterReminderInterval: number; // 分钟
|
||||
medicationReminderEnabled: boolean;
|
||||
nutritionReminderEnabled: boolean;
|
||||
moodReminderEnabled: boolean;
|
||||
}
|
||||
|
||||
// 默认的用户偏好设置
|
||||
@@ -35,11 +39,13 @@ const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
notificationEnabled: true, // 默认开启消息推送
|
||||
fitnessExerciseMinutesInfoDismissed: false, // 默认显示锻炼分钟说明
|
||||
fitnessActiveHoursInfoDismissed: false, // 默认显示活动小时说明
|
||||
waterReminderEnabled: true, // 默认关闭喝水提醒
|
||||
waterReminderEnabled: false, // 默认关闭喝水提醒
|
||||
waterReminderStartTime: '08:00', // 默认开始时间早上8点
|
||||
waterReminderEndTime: '22:00', // 默认结束时间晚上10点
|
||||
waterReminderInterval: 60, // 默认提醒间隔60分钟
|
||||
medicationReminderEnabled: true, // 默认开启药品提醒
|
||||
nutritionReminderEnabled: true, // 默认开启营养提醒
|
||||
moodReminderEnabled: true, // 默认开启心情提醒
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -57,6 +63,8 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
||||
const waterReminderEndTime = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME);
|
||||
const waterReminderInterval = await AsyncStorage.getItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
|
||||
const medicationReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
|
||||
const nutritionReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
|
||||
const moodReminderEnabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
|
||||
|
||||
return {
|
||||
quickWaterAmount: quickWaterAmount ? parseInt(quickWaterAmount, 10) : DEFAULT_PREFERENCES.quickWaterAmount,
|
||||
@@ -69,6 +77,8 @@ export const getUserPreferences = async (): Promise<UserPreferences> => {
|
||||
waterReminderEndTime: waterReminderEndTime || DEFAULT_PREFERENCES.waterReminderEndTime,
|
||||
waterReminderInterval: waterReminderInterval ? parseInt(waterReminderInterval, 10) : DEFAULT_PREFERENCES.waterReminderInterval,
|
||||
medicationReminderEnabled: medicationReminderEnabled !== null ? medicationReminderEnabled === 'true' : DEFAULT_PREFERENCES.medicationReminderEnabled,
|
||||
nutritionReminderEnabled: nutritionReminderEnabled !== null ? nutritionReminderEnabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled,
|
||||
moodReminderEnabled: moodReminderEnabled !== null ? moodReminderEnabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取用户偏好设置失败:', error);
|
||||
@@ -381,6 +391,8 @@ export const resetUserPreferences = async (): Promise<void> => {
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_END_TIME);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.WATER_REMINDER_INTERVAL);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.MEDICATION_REMINDER_ENABLED);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
|
||||
await AsyncStorage.removeItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
|
||||
} catch (error) {
|
||||
console.error('重置用户偏好设置失败:', error);
|
||||
throw error;
|
||||
@@ -412,3 +424,55 @@ export const getMedicationReminderEnabled = async (): Promise<boolean> => {
|
||||
return DEFAULT_PREFERENCES.medicationReminderEnabled;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置营养提醒开关
|
||||
* @param enabled 是否开启营养提醒
|
||||
*/
|
||||
export const setNutritionReminderEnabled = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED, enabled.toString());
|
||||
} catch (error) {
|
||||
console.error('设置营养提醒开关失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取营养提醒开关状态
|
||||
*/
|
||||
export const getNutritionReminderEnabled = async (): Promise<boolean> => {
|
||||
try {
|
||||
const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.NUTRITION_REMINDER_ENABLED);
|
||||
return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.nutritionReminderEnabled;
|
||||
} catch (error) {
|
||||
console.error('获取营养提醒开关状态失败:', error);
|
||||
return DEFAULT_PREFERENCES.nutritionReminderEnabled;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置心情提醒开关
|
||||
* @param enabled 是否开启心情提醒
|
||||
*/
|
||||
export const setMoodReminderEnabled = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
await AsyncStorage.setItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED, enabled.toString());
|
||||
} catch (error) {
|
||||
console.error('设置心情提醒开关失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取心情提醒开关状态
|
||||
*/
|
||||
export const getMoodReminderEnabled = async (): Promise<boolean> => {
|
||||
try {
|
||||
const enabled = await AsyncStorage.getItem(PREFERENCES_KEYS.MOOD_REMINDER_ENABLED);
|
||||
return enabled !== null ? enabled === 'true' : DEFAULT_PREFERENCES.moodReminderEnabled;
|
||||
} catch (error) {
|
||||
console.error('获取心情提醒开关状态失败:', error);
|
||||
return DEFAULT_PREFERENCES.moodReminderEnabled;
|
||||
}
|
||||
};
|
||||