Compare commits
30 Commits
v1.0.27
...
feature/he
| Author | SHA1 | Date | |
|---|---|---|---|
| 409f125db1 | |||
|
|
eef0134ddc | ||
|
|
0013dc3266 | ||
|
|
37a0687456 | ||
|
|
74b49efe23 | ||
|
|
3d08721474 | ||
|
|
f3d4264b53 | ||
|
|
a254af92c7 | ||
|
|
e713ffbace | ||
|
|
02b2de3ea3 | ||
|
|
5b46104564 | ||
|
|
be0dd750eb | ||
|
|
a47f0fb72e | ||
| a309123b35 | |||
| 83b77615cf | |||
|
|
bca6670390 | ||
|
|
fbe0c92f0f | ||
|
|
08adf0f20d | ||
|
|
18d83091a9 | ||
|
|
01388a5c4f | ||
|
|
518282ecb8 | ||
|
|
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,20 +166,24 @@
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 短期目标(1-2个月)
|
||||
1. 完善自动化测试覆盖
|
||||
2. 优化应用启动性能
|
||||
3. 增强错误监控和分析
|
||||
4. 改进用户引导流程
|
||||
### 短期目标(1-2 个月)
|
||||
|
||||
1. 完善所有核心页面的多语言翻译支持
|
||||
2. 完善自动化测试覆盖
|
||||
3. 优化应用启动性能
|
||||
4. 增强错误监控和分析
|
||||
5. 改进用户引导流程
|
||||
|
||||
### 中期目标(3-6 个月)
|
||||
|
||||
### 中期目标(3-6个月)
|
||||
1. 扩展 AI 教练功能
|
||||
2. 增加更多健康指标追踪
|
||||
3. 优化数据同步策略
|
||||
4. 增强社交功能
|
||||
|
||||
### 长期目标(6个月以上)
|
||||
### 长期目标(6 个月以上)
|
||||
|
||||
1. 支持 Apple Watch 应用
|
||||
2. 集成更多第三方健康设备
|
||||
3. 开发 Web 端管理界面
|
||||
4. 扩展企业健康解决方案
|
||||
4. 扩展企业健康解决方案
|
||||
|
||||
@@ -5,26 +5,31 @@
|
||||
**最后更新**: 2025-10-24
|
||||
|
||||
### 重要规则
|
||||
|
||||
**项目中不允许使用 MaterialIcons**,所有图标必须使用 Ionicons 以保持图标库的一致性。
|
||||
|
||||
### 问题描述
|
||||
|
||||
在项目中发现使用 MaterialIcons 的情况,需要将所有 MaterialIcons 替换为 Ionicons,以保持图标库的一致性。
|
||||
|
||||
### 解决方案
|
||||
|
||||
将所有 MaterialIcons 导入和使用替换为对应的 Ionicons。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 替换导入语句
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
|
||||
// ✅ 正确写法
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
```
|
||||
|
||||
#### 2. 替换图标名称和属性
|
||||
|
||||
```typescript
|
||||
// ❌ 禁止使用
|
||||
<MaterialIcons name="arrow-back-ios" size={20} color="#333" />
|
||||
@@ -34,6 +39,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
```
|
||||
|
||||
#### 3. 常见图标映射
|
||||
|
||||
- `arrow-back-ios` → `chevron-back` (返回按钮)
|
||||
- `auto-awesome` → `star` (星星/自动推荐)
|
||||
- `tips-and-updates` → `bulb` (提示/建议)
|
||||
@@ -42,6 +48,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
- `remove` → `remove` (移除/删除,名称相同)
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **图标大小调整**:Ionicons 和 MaterialIcons 的默认大小可能不同,需要适当调整
|
||||
2. **图标名称差异**:两个图标库的图标名称不同,需要找到对应的功能图标
|
||||
3. **样式一致性**:确保替换后的图标在视觉上与原设计保持一致
|
||||
@@ -49,6 +56,7 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
5. **代码审查**:在代码审查中需要特别检查是否使用了 MaterialIcons
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/ui/HeaderBar.tsx` - 返回按钮的标准实现
|
||||
- `components/model/MembershipModal.tsx` - 完整的 MaterialIcons 替换示例
|
||||
|
||||
@@ -57,21 +65,25 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
**最后更新**: 2025-10-24
|
||||
|
||||
### 重要原则
|
||||
|
||||
**所有按钮组件都需要尝试兼容 Liquid Glass**,这是项目的设计要求。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件
|
||||
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
@@ -81,9 +93,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.button}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="icon-name" size={20} color="#333" />
|
||||
</GlassView>
|
||||
@@ -96,26 +108,28 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20, // 圆形按钮
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
||||
borderRadius: 20, // 圆形按钮
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||
// 其他通用样式...
|
||||
},
|
||||
fallbackButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **兼容性检查**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
@@ -124,6 +138,7 @@ const styles = StyleSheet.create({
|
||||
6. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 返回/导航操作:白色系 `rgba(255, 255, 255, 0.3)`
|
||||
@@ -132,6 +147,7 @@ const styles = StyleSheet.create({
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `components/model/MembershipModal.tsx` - 悬浮返回按钮
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
@@ -141,24 +157,29 @@ const styles = StyleSheet.create({
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
当使用 HeaderBar 组件时,需要正确处理内容区域的顶部距离,确保内容不会被状态栏或刘海屏遮挡。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `useSafeAreaTop` hook 获取安全区域顶部距离,并应用到内容容器的样式中。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { useSafeAreaTop } from "@/hooks/useSafeAreaWithPadding";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取 safeAreaTop
|
||||
|
||||
```typescript
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
```
|
||||
|
||||
#### 3. 应用到内容容器
|
||||
|
||||
```typescript
|
||||
// 方式1: 直接应用到 View 组件
|
||||
<View style={[styles.filterContainer, { paddingTop: safeAreaTop }]}>
|
||||
@@ -175,11 +196,13 @@ const safeAreaTop = useSafeAreaTop()
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **不要在 StyleSheet 中使用变量**:不能在 `StyleSheet.create()` 中直接使用 `safeAreaTop` 变量
|
||||
2. **使用动态样式**:必须通过内联样式或数组样式的方式动态应用 `safeAreaTop`
|
||||
3. **不需要额外偏移**:通常只需要 `safeAreaTop`,不需要添加额外的固定像素值
|
||||
|
||||
### 示例代码
|
||||
|
||||
```typescript
|
||||
// ❌ 错误写法 - 在 StyleSheet 中使用变量
|
||||
const styles = StyleSheet.create({
|
||||
@@ -193,6 +216,7 @@ const styles = StyleSheet.create({
|
||||
```
|
||||
|
||||
### 参考页面
|
||||
|
||||
- `app/steps/detail.tsx`
|
||||
- `app/water/detail.tsx`
|
||||
- `app/profile/goals.tsx`
|
||||
@@ -204,24 +228,29 @@ const styles = StyleSheet.create({
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用中实现符合 Liquid Glass 设计风格的图标按钮,需要考虑毛玻璃效果和兼容性处理。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `GlassView` 组件实现毛玻璃效果,并提供不支持 Liquid Glass 的设备的降级方案。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的组件和函数
|
||||
|
||||
```typescript
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
|
||||
```
|
||||
|
||||
#### 2. 检查设备支持情况
|
||||
|
||||
```typescript
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 3. 实现条件渲染的按钮
|
||||
|
||||
```typescript
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
@@ -231,9 +260,9 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassButton}
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
glassEffectStyle="clear" // 或 "regular"
|
||||
tintColor="rgba(244, 67, 54, 0.2)" // 自定义色调
|
||||
isInteractive={true} // 启用交互反馈
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#F44336" />
|
||||
</GlassView>
|
||||
@@ -246,25 +275,27 @@ const isGlassAvailable = isLiquidGlassAvailable();
|
||||
```
|
||||
|
||||
#### 4. 定义样式
|
||||
|
||||
```typescript
|
||||
const styles = StyleSheet.create({
|
||||
glassButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18, // 圆形按钮
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
||||
borderRadius: 18, // 圆形按钮
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden", // 保证玻璃边界圆角效果
|
||||
},
|
||||
fallbackButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(244, 67, 54, 0.3)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
borderColor: "rgba(244, 67, 54, 0.3)",
|
||||
backgroundColor: "rgba(244, 67, 54, 0.1)",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **兼容性处理**:必须使用 `isLiquidGlassAvailable()` 检查设备支持情况
|
||||
2. **overflow: 'hidden'**:GlassView 组件需要设置此属性以保证圆角效果
|
||||
3. **降级样式**:为不支持 Liquid Glass 的设备提供视觉上相似的替代方案
|
||||
@@ -272,6 +303,7 @@ const styles = StyleSheet.create({
|
||||
5. **色调自定义**:通过 `tintColor` 属性自定义按钮的颜色主题
|
||||
|
||||
### 常用配置
|
||||
|
||||
- **glassEffectStyle**: "clear"(透明)或 "regular"(常规)
|
||||
- **tintColor**: 根据按钮功能选择合适的颜色
|
||||
- 删除操作:红色系 `rgba(244, 67, 54, 0.2)`
|
||||
@@ -279,6 +311,7 @@ const styles = StyleSheet.create({
|
||||
- 信息操作:蓝色系 `rgba(33, 150, 243, 0.2)`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/food/nutrition-analysis-history.tsx` - 删除按钮实现
|
||||
- `components/glass/button.tsx` - 通用 Glass 按钮组件
|
||||
- `app/(tabs)/_layout.tsx` - 标签栏按钮实现
|
||||
@@ -288,27 +321,33 @@ const styles = StyleSheet.create({
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。
|
||||
|
||||
### 权限校验原则
|
||||
|
||||
**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useAuthGuard } from "@/hooks/useAuthGuard";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取方法
|
||||
|
||||
```typescript
|
||||
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 3. 替换导航操作
|
||||
|
||||
```typescript
|
||||
// ❌ 原来的写法 - 没有登录验证
|
||||
<TouchableOpacity
|
||||
@@ -324,6 +363,7 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 4. 服务端接口调用的登录验证
|
||||
|
||||
对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证:
|
||||
|
||||
```typescript
|
||||
@@ -347,33 +387,37 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
```
|
||||
|
||||
#### 5. 完整示例(包含 Liquid Glass 兼容性处理)
|
||||
|
||||
```typescript
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.historyButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
{
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.historyButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.2)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin("/food/nutrition-analysis-history")}
|
||||
style={[styles.historyButton, styles.fallbackBackground]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')}
|
||||
style={[styles.historyButton, styles.fallbackBackground]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="time-outline" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面
|
||||
2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })`
|
||||
3. **登录重定向**:登录页面会接收 `redirectTo` 和 `redirectParams` 参数用于登录后跳转
|
||||
@@ -382,16 +426,19 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
6. **异步处理**:`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果
|
||||
|
||||
### 其他可用方法
|
||||
|
||||
- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录
|
||||
- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录
|
||||
- `isLoggedIn` - 布尔值,表示当前用户是否已登录
|
||||
|
||||
### 使用场景选择
|
||||
|
||||
- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转
|
||||
- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能
|
||||
- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证
|
||||
- `app/(tabs)/personal.tsx` - 个人中心编辑按钮
|
||||
- `hooks/useAuthGuard.ts` - 完整的认证守卫实现
|
||||
@@ -401,39 +448,44 @@ const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
**最后更新**: 2025-10-16
|
||||
|
||||
### 问题描述
|
||||
|
||||
在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。
|
||||
|
||||
### 解决方案
|
||||
|
||||
将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 添加新路由常量
|
||||
|
||||
在 `constants/Routes.ts` 文件中添加新的路由常量:
|
||||
|
||||
```typescript
|
||||
export const ROUTES = {
|
||||
// 现有路由...
|
||||
|
||||
|
||||
// 新增路由
|
||||
FOOD_CAMERA: '/food/camera',
|
||||
FOOD_CAMERA: "/food/camera",
|
||||
} as const;
|
||||
```
|
||||
|
||||
#### 2. 在组件中使用路由常量
|
||||
|
||||
导入并使用路由常量,而不是硬编码路径:
|
||||
|
||||
```typescript
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { ROUTES } from "@/constants/Routes";
|
||||
|
||||
// ❌ 错误写法 - 硬编码路径
|
||||
router.push('/food/camera?mealType=dinner');
|
||||
router.push("/food/camera?mealType=dinner");
|
||||
|
||||
// ✅ 正确写法 - 使用路由常量
|
||||
router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`);
|
||||
```
|
||||
|
||||
#### 3. 结合登录验证使用
|
||||
|
||||
对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用:
|
||||
|
||||
```typescript
|
||||
@@ -450,6 +502,7 @@ const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义
|
||||
2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA`
|
||||
3. **路径一致性**:常量名应该清晰表达路由的用途
|
||||
@@ -457,26 +510,244 @@ const { pushIfAuthedElseLogin } = useAuthGuard();
|
||||
5. **类型安全**:使用 `as const` 确保类型推导
|
||||
|
||||
### 路由分类
|
||||
|
||||
按照功能模块对路由进行分组:
|
||||
|
||||
```typescript
|
||||
export const ROUTES = {
|
||||
// Tab路由
|
||||
TAB_EXPLORE: '/explore',
|
||||
TAB_COACH: '/coach',
|
||||
|
||||
TAB_EXPLORE: "/explore",
|
||||
TAB_COACH: "/coach",
|
||||
|
||||
// 营养相关路由
|
||||
NUTRITION_RECORDS: '/nutrition/records',
|
||||
FOOD_LIBRARY: '/food-library',
|
||||
FOOD_CAMERA: '/food/camera',
|
||||
|
||||
NUTRITION_RECORDS: "/nutrition/records",
|
||||
FOOD_LIBRARY: "/food-library",
|
||||
FOOD_CAMERA: "/food/camera",
|
||||
|
||||
// 用户相关路由
|
||||
AUTH_LOGIN: '/auth/login',
|
||||
PROFILE_EDIT: '/profile/edit',
|
||||
AUTH_LOGIN: "/auth/login",
|
||||
PROFILE_EDIT: "/profile/edit",
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `constants/Routes.ts` - 路由常量定义
|
||||
- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证
|
||||
- `app/food/camera.tsx` - 食物拍照页面实现
|
||||
- `app/food/camera.tsx` - 食物拍照页面实现
|
||||
|
||||
## 多语言翻译实现规范
|
||||
|
||||
**最后更新**: 2025-11-26
|
||||
|
||||
### 重要原则
|
||||
|
||||
**所有用户可见的文本都必须支持多语言翻译**,这是项目的基本要求。不允许在代码中硬编码任何用户可见的中文或英文文本。
|
||||
|
||||
### 问题描述
|
||||
|
||||
在开发新功能或修改现有功能时,所有用户界面文本都需要支持多语言切换,确保应用能够为不同语言用户提供本地化体验。
|
||||
|
||||
### 解决方案
|
||||
|
||||
使用项目集成的 i18next 翻译系统,在 `i18n/index.ts` 中定义翻译资源,在组件中使用 `useI18n` hook 获取翻译文本。
|
||||
|
||||
### 实现模式
|
||||
|
||||
#### 1. 导入必要的 hook
|
||||
|
||||
```typescript
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
```
|
||||
|
||||
#### 2. 在组件中获取翻译函数
|
||||
|
||||
```typescript
|
||||
const { t } = useI18n();
|
||||
```
|
||||
|
||||
#### 3. 添加翻译资源
|
||||
|
||||
在 `i18n/index.ts` 中为新的功能模块添加翻译资源:
|
||||
|
||||
```typescript
|
||||
// 中文翻译
|
||||
const newFeatureResources = {
|
||||
title: "新功能标题",
|
||||
subtitle: "新功能描述",
|
||||
button: "按钮文本",
|
||||
loading: "加载中...",
|
||||
error: "操作失败,请稍后重试",
|
||||
success: "操作成功",
|
||||
};
|
||||
|
||||
// 英文翻译
|
||||
const newFeatureResourcesEn = {
|
||||
title: "New Feature Title",
|
||||
subtitle: "New feature description",
|
||||
button: "Button Text",
|
||||
loading: "Loading...",
|
||||
error: "Operation failed, please try again later",
|
||||
success: "Operation successful",
|
||||
};
|
||||
|
||||
// 添加到资源对象中
|
||||
resources = {
|
||||
zh: {
|
||||
translation: {
|
||||
// 现有翻译...
|
||||
newFeature: newFeatureResources,
|
||||
},
|
||||
},
|
||||
en: {
|
||||
translation: {
|
||||
// 现有翻译...
|
||||
newFeature: newFeatureResourcesEn,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 在组件中使用翻译
|
||||
|
||||
```typescript
|
||||
// ❌ 错误写法 - 硬编码文本
|
||||
<Text>加载中...</Text>
|
||||
<Text>操作失败,请稍后重试</Text>
|
||||
|
||||
// ✅ 正确写法 - 使用翻译函数
|
||||
<Text>{t('newFeature.loading')}</Text>
|
||||
<Text>{t('newFeature.error')}</Text>
|
||||
```
|
||||
|
||||
#### 5. 动态参数翻译
|
||||
|
||||
对于包含动态参数的文本,使用插值语法:
|
||||
|
||||
```typescript
|
||||
// 翻译资源中
|
||||
welcome: '欢迎,{{name}}!'
|
||||
itemsCount: '共 {{count}} 个项目'
|
||||
|
||||
// 组件中使用
|
||||
<Text>{t('newFeature.welcome', { name: userName })}</Text>
|
||||
<Text>{t('newFeature.itemsCount', { count: items.length })}</Text>
|
||||
```
|
||||
|
||||
#### 6. 嵌套翻译键
|
||||
|
||||
对于复杂功能,使用嵌套的翻译键结构:
|
||||
|
||||
```typescript
|
||||
// 翻译资源
|
||||
modal: {
|
||||
title: '确认操作',
|
||||
description: '确定要执行此操作吗?',
|
||||
buttons: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
},
|
||||
}
|
||||
|
||||
// 组件中使用
|
||||
<Text>{t('newFeature.modal.title')}</Text>
|
||||
<Text>{t('newFeature.modal.buttons.confirm')}</Text>
|
||||
```
|
||||
|
||||
### 重要注意事项
|
||||
|
||||
1. **禁止硬编码**:所有用户可见的文本都必须通过翻译函数获取
|
||||
2. **完整翻译**:中文和英文翻译都必须提供,保持翻译完整性
|
||||
3. **语义化命名**:翻译键应该清晰表达文本的用途和含义
|
||||
4. **参数化文本**:包含动态内容的文本应该使用插值参数
|
||||
5. **一致性**:相同功能的文本应该使用相同的翻译键
|
||||
6. **Toast 消息**:Toast 提示消息也需要翻译支持
|
||||
7. **错误消息**:错误提示信息必须支持多语言
|
||||
8. **表单验证**:表单验证错误信息需要翻译
|
||||
|
||||
### 常见翻译模式
|
||||
|
||||
#### 1. 状态文本
|
||||
|
||||
```typescript
|
||||
status: {
|
||||
loading: '加载中...',
|
||||
success: '操作成功',
|
||||
error: '操作失败',
|
||||
empty: '暂无数据',
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 按钮文本
|
||||
|
||||
```typescript
|
||||
buttons: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
add: '添加',
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 表单相关
|
||||
|
||||
```typescript
|
||||
form: {
|
||||
placeholders: {
|
||||
email: '请输入邮箱地址',
|
||||
password: '请输入密码',
|
||||
},
|
||||
errors: {
|
||||
required: '此字段为必填项',
|
||||
invalid: '格式不正确',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 列表和表格
|
||||
|
||||
```typescript
|
||||
list: {
|
||||
empty: '暂无数据',
|
||||
loading: '加载中...',
|
||||
loadMore: '加载更多',
|
||||
refresh: '刷新',
|
||||
}
|
||||
```
|
||||
|
||||
### 翻译键命名规范
|
||||
|
||||
1. **使用小写字母和点号分隔**:`feature.section.item`
|
||||
2. **按功能模块分组**:`challenges.title`, `challenges.subtitle`
|
||||
3. **语义化命名**:`buttons.confirm`, `errors.network`
|
||||
4. **避免缩写**:使用 `description` 而不是 `desc`
|
||||
|
||||
### 参考实现
|
||||
|
||||
- `app/(tabs)/challenges.tsx` - 完整的多语言翻译实现示例
|
||||
- `i18n/index.ts` - 翻译资源配置
|
||||
- `hooks/useI18n.ts` - 翻译 hook 实现
|
||||
- `app/(tabs)/personal.tsx` - 个人中心页面翻译实现
|
||||
- `app/food/nutrition-label-analysis.tsx` - 营养分析页面翻译实现
|
||||
|
||||
### 检查清单
|
||||
|
||||
在开发新功能时,请确保:
|
||||
|
||||
- [ ] 所有用户可见的文本都使用了翻译函数
|
||||
- [ ] 在 `i18n/index.ts` 中添加了对应的中文和英文翻译
|
||||
- [ ] Toast 消息支持多语言
|
||||
- [ ] 错误提示信息支持多语言
|
||||
- [ ] 表单验证错误信息支持多语言
|
||||
- [ ] 动态参数文本使用了插值语法
|
||||
- [ ] 翻译键命名符合规范
|
||||
- [ ] 测试了语言切换功能
|
||||
|
||||
### 最佳实践
|
||||
|
||||
1. **开发时即考虑多语言**:在编写组件时就使用翻译函数,而不是事后添加
|
||||
2. **保持翻译一致性**:相同含义的文本使用相同的翻译键
|
||||
3. **定期审查**:定期检查是否有硬编码文本遗漏
|
||||
4. **测试验证**:在开发完成后测试语言切换功能是否正常
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,5 +4,6 @@
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
},
|
||||
"kiroAgent.configureMCP": "Enabled"
|
||||
"kiroAgent.configureMCP": "Enabled",
|
||||
"codingcopilot.enableCompletionLanguage": {}
|
||||
}
|
||||
|
||||
11
app.json
11
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Out Live",
|
||||
"slug": "digital-pilates",
|
||||
"version": "1.0.20",
|
||||
"version": "1.1.4",
|
||||
"orientation": "portrait",
|
||||
"scheme": "digitalpilates",
|
||||
"userInterfaceStyle": "light",
|
||||
@@ -36,6 +36,7 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-updates",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
@@ -70,8 +71,16 @@
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"android": {
|
||||
"package": "com.anonymous.digitalpilates"
|
||||
},
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkAutomatically": "ON_LOAD",
|
||||
"url": "https://pilate.richarjiang.com/api/expo-updates/manifest"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
@@ -21,11 +23,11 @@ type TabConfig = {
|
||||
};
|
||||
|
||||
const TAB_CONFIGS: Record<string, TabConfig> = {
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'statistics.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'statistics.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'statistics.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'statistics.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'statistics.tabs.personal' },
|
||||
statistics: { icon: 'chart.pie.fill', titleKey: 'health.tabs.health' },
|
||||
medications: { icon: 'pills.fill', titleKey: 'health.tabs.medications' },
|
||||
fasting: { icon: 'timer', titleKey: 'health.tabs.fasting' },
|
||||
challenges: { icon: 'trophy.fill', titleKey: 'health.tabs.challenges' },
|
||||
personal: { icon: 'person.fill', titleKey: 'health.tabs.personal' },
|
||||
};
|
||||
|
||||
export default function TabLayout() {
|
||||
@@ -34,6 +36,9 @@ export default function TabLayout() {
|
||||
const colorTokens = Colors[theme];
|
||||
const pathname = usePathname();
|
||||
const glassEffectAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取已启用的标签配置(按自定义顺序)
|
||||
const enabledTabs = useAppSelector(selectEnabledTabs);
|
||||
|
||||
// Helper function to determine if a tab is selected
|
||||
const isTabSelected = (routeName: string): boolean => {
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
<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') }} />
|
||||
{enabledTabs.map((tab) => {
|
||||
const tabConfig = TAB_CONFIGS[tab.id];
|
||||
if (!tabConfig) return null;
|
||||
|
||||
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]}
|
||||
@@ -447,34 +602,82 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 26,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 1,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
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: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
minWidth: 70,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
createButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
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,17 @@
|
||||
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 { MedicationAiSummaryInfoSheet } from '@/components/ui/MedicationAiSummaryInfoSheet';
|
||||
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 +49,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 +59,44 @@ 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);
|
||||
const [aiSummaryInfoVisible, setAiSummaryInfoVisible] = useState(false);
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const selectedKey = selectedDate.format('YYYY-MM-DD');
|
||||
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
|
||||
|
||||
// 使用 useMemo 缓存 selector 实例,避免每次渲染都创建新的 selector
|
||||
const medicationSelector = useMemo(
|
||||
() => selectMedicationDisplayItemsByDate(selectedKey),
|
||||
[selectedKey]
|
||||
);
|
||||
const medicationsForDay = useAppSelector(medicationSelector);
|
||||
|
||||
const handleOpenAddMedication = useCallback(() => {
|
||||
// 检查是否已经读过免责声明
|
||||
// 直接跳转到 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 +105,43 @@ 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 handleOpenAiSummary = useCallback(async () => {
|
||||
// 先检查登录状态
|
||||
const isLoggedIn = await ensureLoggedIn();
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// 检查 VIP 权限
|
||||
const access = checkServiceAccess();
|
||||
if (!access.canUseService) {
|
||||
// 非会员显示介绍弹窗
|
||||
setAiSummaryInfoVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 会员直接跳转到 AI 总结页面
|
||||
router.push('/medications/ai-summary');
|
||||
}, [checkServiceAccess, ensureLoggedIn]);
|
||||
|
||||
const handleAiSummaryInfoConfirm = useCallback(() => {
|
||||
setAiSummaryInfoVisible(false);
|
||||
// 点击"我要订阅"后,弹出会员订阅弹窗
|
||||
openMembershipModal();
|
||||
}, [openMembershipModal]);
|
||||
|
||||
const handleAiSummaryInfoClose = useCallback(() => {
|
||||
setAiSummaryInfoVisible(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenMedicationManagement = useCallback(() => {
|
||||
@@ -111,9 +173,11 @@ export default function MedicationsScreen() {
|
||||
|
||||
// 加载药物和记录数据
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
dispatch(fetchMedications());
|
||||
dispatch(fetchMedicationRecords({ date: selectedKey }));
|
||||
}, [dispatch, selectedKey]);
|
||||
}, [dispatch, selectedKey, isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -126,17 +190,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 +221,7 @@ export default function MedicationsScreen() {
|
||||
};
|
||||
|
||||
refreshDataAndRescheduleNotifications();
|
||||
}, [dispatch, selectedKey])
|
||||
}, [dispatch, selectedKey, isLoggedIn])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -189,6 +252,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(待服用)
|
||||
@@ -241,31 +314,59 @@ export default function MedicationsScreen() {
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenMedicationManagement}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAiSummary}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
tintColor="rgba(255, 255, 255, 0.36)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
<IconSymbol name="sparkles" size={18} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAiSummary}
|
||||
style={[styles.headerAddButton, styles.fallbackAddButton]}
|
||||
>
|
||||
<IconSymbol name="sparkles" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenAddMedication}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenMedicationManagement}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleOpenMedicationManagement}
|
||||
style={[styles.headerAddButton, styles.fallbackAddButton]}
|
||||
>
|
||||
<IconSymbol name="pills.fill" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleAddMedication}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.headerAddButton}
|
||||
glassEffectStyle="clear"
|
||||
@@ -274,12 +375,16 @@ export default function MedicationsScreen() {
|
||||
>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.headerAddButton, styles.fallbackAddButton]}>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleAddMedication}
|
||||
style={[styles.headerAddButton, styles.fallbackAddButton]}
|
||||
>
|
||||
<IconSymbol name="plus" size={18} color="#333" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -354,7 +459,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 +470,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>
|
||||
@@ -374,6 +491,13 @@ export default function MedicationsScreen() {
|
||||
onClose={handleDisclaimerClose}
|
||||
onConfirm={handleDisclaimerConfirm}
|
||||
/>
|
||||
|
||||
{/* AI 用药总结介绍弹窗 */}
|
||||
<MedicationAiSummaryInfoSheet
|
||||
visible={aiSummaryInfoVisible}
|
||||
onClose={handleAiSummaryInfoClose}
|
||||
onConfirm={handleAiSummaryInfoConfirm}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -442,12 +566,14 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 30,
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
welcome: {
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
sectionSpacing: {
|
||||
gap: 16,
|
||||
@@ -458,10 +584,12 @@ const styles = StyleSheet.create({
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sectionHeader: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
segmentedControl: {
|
||||
flexDirection: 'row',
|
||||
@@ -481,6 +609,7 @@ const styles = StyleSheet.create({
|
||||
segmentLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
segmentBadge: {
|
||||
minWidth: 24,
|
||||
@@ -493,6 +622,7 @@ const styles = StyleSheet.create({
|
||||
segmentBadgeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
@@ -510,11 +640,13 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
primaryButton: {
|
||||
marginTop: 8,
|
||||
@@ -528,6 +660,7 @@ const styles = StyleSheet.create({
|
||||
primaryButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
cardsWrapper: {
|
||||
gap: 16,
|
||||
@@ -541,5 +674,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { MembershipBanner } from '@/components/MembershipBanner';
|
||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useVersionCheck } from '@/contexts/VersionCheckContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||
import { updateUser, type UserLanguage } from '@/services/users';
|
||||
import { getCurrentAppVersion } from '@/services/version';
|
||||
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
|
||||
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
@@ -52,17 +59,26 @@ type LanguageOption = {
|
||||
};
|
||||
|
||||
export default function PersonalScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const { confirmLogout, confirmDeleteAccount, isLoggedIn, pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t, i18n } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const isLgAvaliable = isLiquidGlassAvailable();
|
||||
const [languageModalVisible, setLanguageModalVisible] = useState(false);
|
||||
const [isSwitchingLanguage, setIsSwitchingLanguage] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { checkForUpdate, isChecking: isCheckingVersion, updateInfo } = useVersionCheck();
|
||||
|
||||
const gradientColors: [string, string] =
|
||||
theme === 'dark'
|
||||
? ['#1f2230', '#10131e']
|
||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||
|
||||
const languageOptions = useMemo<LanguageOption[]>(() => ([
|
||||
{
|
||||
@@ -78,7 +94,17 @@ 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 currentAppVersion = useMemo(() => getCurrentAppVersion(), []);
|
||||
const versionRightText = useMemo(() => {
|
||||
if (isCheckingVersion) {
|
||||
return t('personal.versionCheck.checking');
|
||||
}
|
||||
if (updateInfo?.needsUpdate) {
|
||||
return t('personal.versionCheck.updateBadge', { version: updateInfo.latestVersion });
|
||||
}
|
||||
return `v${currentAppVersion}`;
|
||||
}, [currentAppVersion, isCheckingVersion, t, updateInfo?.latestVersion, updateInfo?.needsUpdate]);
|
||||
|
||||
const handleLanguageSelect = useCallback(async (language: AppLanguage) => {
|
||||
setLanguageModalVisible(false);
|
||||
@@ -87,13 +113,33 @@ export default function PersonalScreen() {
|
||||
}
|
||||
try {
|
||||
setIsSwitchingLanguage(true);
|
||||
|
||||
// 将 AppLanguage ('zh' | 'en') 映射到 UserLanguage ('zh-CN' | 'en-US')
|
||||
const languageMap: Record<AppLanguage, UserLanguage> = {
|
||||
'zh': 'zh-CN',
|
||||
'en': 'en-US',
|
||||
};
|
||||
const userLanguage = languageMap[language];
|
||||
|
||||
// 先切换本地语言
|
||||
await changeAppLanguage(language);
|
||||
|
||||
// 如果用户已登录,同步更新服务器语言设置
|
||||
if (isLoggedIn) {
|
||||
try {
|
||||
await updateUser({ language: userLanguage });
|
||||
log.info('语言设置已同步到服务器', { language: userLanguage });
|
||||
} catch (error) {
|
||||
log.warn('同步语言设置到服务器失败', error);
|
||||
// 服务器更新失败不影响本地语言切换,静默处理
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('语言切换失败', error);
|
||||
} finally {
|
||||
setIsSwitchingLanguage(false);
|
||||
}
|
||||
}, [activeLanguageCode, isSwitchingLanguage]);
|
||||
}, [activeLanguageCode, isSwitchingLanguage, isLoggedIn]);
|
||||
|
||||
// 推送通知设置仅在独立页面管理
|
||||
|
||||
@@ -163,22 +209,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])
|
||||
);
|
||||
|
||||
// 手动刷新处理
|
||||
@@ -221,25 +270,6 @@ export default function PersonalScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 数据格式化函数
|
||||
const formatHeight = () => {
|
||||
if (userProfile.height == null) return '--';
|
||||
return `${parseFloat(userProfile.height).toFixed(1)}cm`;
|
||||
};
|
||||
|
||||
const formatWeight = () => {
|
||||
if (userProfile.weight == null) return '--';
|
||||
return `${parseFloat(userProfile.weight).toFixed(1)}kg`;
|
||||
};
|
||||
|
||||
const formatAge = () => {
|
||||
if (!userProfile.birthDate) return '--';
|
||||
const birthDate = new Date(userProfile.birthDate);
|
||||
const today = new Date();
|
||||
const age = today.getFullYear() - birthDate.getFullYear();
|
||||
return `${age}${t('personal.stats.ageSuffix')}`;
|
||||
};
|
||||
|
||||
// 显示名称
|
||||
const displayName = (userProfile.name?.trim()) ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||
const profileActionLabel = isLoggedIn ? t('personal.edit') : t('personal.login');
|
||||
@@ -299,11 +329,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" />
|
||||
@@ -330,25 +360,6 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
const MembershipBanner = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => {
|
||||
void handleMembershipPress();
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/banner/vip2.png' }}
|
||||
style={styles.membershipBannerImage}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const VipMembershipCard = () => {
|
||||
const fallbackProfile = userProfile as Record<string, unknown>;
|
||||
const fallbackExpire = ['membershipExpiration', 'vipExpiredAt', 'vipExpiresAt', 'vipExpireDate']
|
||||
@@ -364,8 +375,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 (
|
||||
@@ -415,72 +426,64 @@ export default function PersonalScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
// 数据统计部分
|
||||
const StatsSection = () => (
|
||||
// 健康档案入口组件
|
||||
const HealthProfileEntry = () => (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={[styles.cardContainer, {
|
||||
backgroundColor: 'unset'
|
||||
}]}>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatHeight()}</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.height')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.healthProfileCard}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push(ROUTES.HEALTH_PROFILE)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FFFFFF', '#F0F4FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.healthProfileGradient}
|
||||
>
|
||||
<View style={styles.healthProfileContent}>
|
||||
<View style={styles.healthProfileLeft}>
|
||||
<View style={styles.healthProfileTitleRow}>
|
||||
<Text style={styles.healthProfileTitle}>{t('personal.healthProfile.title') || '健康档案'}</Text>
|
||||
</View>
|
||||
<Text style={styles.healthProfileSubtitle}>{t('personal.healthProfile.subtitle') || '管理您的个人健康数据与家庭档案'}</Text>
|
||||
</View>
|
||||
<View style={styles.healthProfileRight}>
|
||||
<Ionicons name="chevron-forward" size={20} color="#9CA3AF" />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatWeight()}</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.weight')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{formatAge()}</Text>
|
||||
<Text style={styles.statLabel}>{t('personal.stats.age')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</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 +493,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 +582,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 +633,30 @@ 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),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('personal.versionCheck.sectionTitle'),
|
||||
items: [
|
||||
{
|
||||
icon: 'cloud-download-outline' as React.ComponentProps<typeof Ionicons>['name'],
|
||||
title: t('personal.versionCheck.menuTitle'),
|
||||
onPress: () => {
|
||||
void checkForUpdate({ manual: true });
|
||||
},
|
||||
rightText: versionRightText,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -671,8 +745,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" />
|
||||
@@ -693,21 +771,15 @@ export default function PersonalScreen() {
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle={'dark-content'} backgroundColor="transparent" translucent />
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<StatusBar barStyle={theme === 'dark' ? 'light-content' : 'dark-content'} backgroundColor="transparent" translucent />
|
||||
|
||||
{/* 背景渐变 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={gradientColors}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{
|
||||
@@ -727,8 +799,8 @@ export default function PersonalScreen() {
|
||||
}
|
||||
>
|
||||
<UserHeader />
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||
<StatsSection />
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner onPress={() => void handleMembershipPress()} />}
|
||||
<HealthProfileEntry />
|
||||
<BadgesPreviewSection />
|
||||
<View style={styles.fishRecordContainer}>
|
||||
{/* <Image
|
||||
@@ -760,33 +832,6 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradientBackground: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
right: 20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.1,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: -15,
|
||||
left: -15,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#0EA5E9',
|
||||
opacity: 0.05,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -812,11 +857,6 @@ const styles = StyleSheet.create({
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
membershipBannerImage: {
|
||||
width: '100%',
|
||||
height: 180,
|
||||
borderRadius: 16,
|
||||
},
|
||||
vipCard: {
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
@@ -951,16 +991,20 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
userRole: {
|
||||
fontSize: 14,
|
||||
color: '#9370DB',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliBold',
|
||||
|
||||
},
|
||||
userMemberNumber: {
|
||||
fontSize: 10,
|
||||
color: '#6C757D',
|
||||
marginTop: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
aiUsageContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -972,6 +1016,7 @@ const styles = StyleSheet.create({
|
||||
color: '#9370DB',
|
||||
marginLeft: 2,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
editButton: {
|
||||
backgroundColor: '#9370DB',
|
||||
@@ -990,6 +1035,7 @@ const styles = StyleSheet.create({
|
||||
color: 'white',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
editButtonTextGlass: {
|
||||
color: 'rgba(147, 112, 219, 1)',
|
||||
@@ -1011,11 +1057,13 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
color: '#9370DB',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#6C757D',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
badgesRowCard: {
|
||||
flexDirection: 'row',
|
||||
@@ -1035,6 +1083,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
badgesRowContent: {
|
||||
flexDirection: 'row',
|
||||
@@ -1073,6 +1122,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#475467',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
badgeCompactOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -1092,11 +1142,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#5B21B6',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
badgesRowEmpty: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliBold',
|
||||
|
||||
},
|
||||
// 菜单项
|
||||
menuItem: {
|
||||
@@ -1121,6 +1174,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
color: '#6C757D',
|
||||
marginRight: 6,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 32,
|
||||
@@ -1149,6 +1203,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
languageModalOverlay: {
|
||||
flex: 1,
|
||||
@@ -1174,11 +1229,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#2C3E50',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
languageModalSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6C757D',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
languageOption: {
|
||||
flexDirection: 'row',
|
||||
@@ -1203,11 +1260,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#2C3E50',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
languageOptionDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6C757D',
|
||||
marginTop: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
languageModalClose: {
|
||||
marginTop: 4,
|
||||
@@ -1217,5 +1276,62 @@ const styles = StyleSheet.create({
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
color: '#9370DB',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
// 健康档案入口样式
|
||||
healthProfileCard: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
healthProfileGradient: {
|
||||
padding: 16,
|
||||
},
|
||||
healthProfileContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
healthProfileLeft: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
healthProfileTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
healthProfileTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
marginRight: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
healthStatusBadge: {
|
||||
backgroundColor: '#ECFDF5',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#A7F3D0',
|
||||
},
|
||||
healthStatusText: {
|
||||
fontSize: 10,
|
||||
color: '#059669',
|
||||
fontWeight: '600',
|
||||
},
|
||||
healthProfileSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
healthProfileRight: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,17 +14,19 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
||||
import { syncDailyHealthReport, syncHealthKitToServer } from '@/services/healthKitSync';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { fetchHealthDataForDate } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -62,9 +64,11 @@ export default function ExploreScreen() {
|
||||
const { t } = useTranslation();
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
const todayWaterStats = useAppSelector((s) => s.water.todayStats);
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||
const router = useRouter();
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -80,7 +84,11 @@ export default function ExploreScreen() {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
|
||||
const handleOpenGallery = React.useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/gallery');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
@@ -287,6 +295,7 @@ export default function ExploreScreen() {
|
||||
try {
|
||||
logger.info('开始同步 HealthKit 个人健康数据到服务端...');
|
||||
|
||||
// 1. 同步个人资料 (身高、体重、出生日期)
|
||||
// 传入当前用户资料,用于 diff 比较
|
||||
const success = await syncHealthKitToServer(
|
||||
async (data) => {
|
||||
@@ -296,20 +305,36 @@ export default function ExploreScreen() {
|
||||
);
|
||||
|
||||
if (success) {
|
||||
logger.info('HealthKit 数据同步到服务端成功');
|
||||
logger.info('HealthKit 个人资料同步到服务端成功');
|
||||
} else {
|
||||
logger.info('HealthKit 数据同步到服务端跳过(无变化)或失败');
|
||||
logger.info('HealthKit 个人资料同步到服务端跳过(无变化)或失败');
|
||||
}
|
||||
|
||||
// 2. 同步每日健康数据报表 (活动、睡眠、心率等)
|
||||
// 传入今日饮水量
|
||||
const waterIntake = todayWaterStats?.totalAmount;
|
||||
logger.info('开始同步每日健康数据报表...', { waterIntake });
|
||||
|
||||
const reportSuccess = await syncDailyHealthReport(waterIntake);
|
||||
|
||||
if (reportSuccess) {
|
||||
logger.info('每日健康数据报表同步成功');
|
||||
} else {
|
||||
logger.info('每日健康数据报表同步跳过(无变化)或失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('同步 HealthKit 数据到服务端失败:', error);
|
||||
}
|
||||
}, [isLoggedIn, dispatch, userProfile]);
|
||||
}, [isLoggedIn, dispatch, userProfile, todayWaterStats]);
|
||||
|
||||
// 初始加载时执行数据加载和同步
|
||||
useEffect(() => {
|
||||
loadAllData(currentSelectedDate);
|
||||
|
||||
// 延迟1秒后执行同步,避免影响初始加载性能
|
||||
// 如果 todayWaterStats 还未加载完成,可能会导致第一次同步时 waterIntake 为 undefined
|
||||
// 但 waterSlice.fetchTodayWaterStats 会在 loadAllData 中被调用
|
||||
const syncTimer = setTimeout(() => {
|
||||
syncHealthDataToServer();
|
||||
}, 1000);
|
||||
@@ -384,42 +409,41 @@ export default function ExploreScreen() {
|
||||
{/* 顶部信息栏 */}
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.headerContent}>
|
||||
{/* 左边logo */}
|
||||
<Image
|
||||
source={require('@/assets/icon.icon/Assets/icon-1756312748268.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.headerLeft}>
|
||||
<Image
|
||||
source={require('@/assets/machine.png')}
|
||||
style={styles.logoImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
|
||||
{/* 右边文字区域 */}
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||
{/* 右边文字区域 */}
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开发环境调试按钮 */}
|
||||
{__DEV__ && (
|
||||
<View style={styles.debugButtonsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.debugButton}
|
||||
onPress={async () => {
|
||||
console.log('🔧 Manual background task test...');
|
||||
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🔧</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={handleOpenGallery}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.liquidGlassButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.debugButton, styles.hrvTestButton]}
|
||||
onPress={async () => {
|
||||
console.log('🫀 Testing HRV data fetch...');
|
||||
await testHRVDataFetch();
|
||||
}}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🫀</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -524,6 +548,7 @@ export default function ExploreScreen() {
|
||||
{/* 血氧饱和度卡片 */}
|
||||
<FloatingCard style={styles.masonryCard}>
|
||||
<OxygenSaturationCard
|
||||
selectedDate={currentSelectedDate}
|
||||
style={styles.basalMetabolismCardOverride}
|
||||
/>
|
||||
</FloatingCard>
|
||||
@@ -536,6 +561,7 @@ export default function ExploreScreen() {
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<CircumferenceCard style={styles.circumferenceCard} />
|
||||
</ScrollView>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -584,6 +610,13 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
logoImage: {
|
||||
width: 28,
|
||||
@@ -598,6 +631,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
debugButtonsContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -619,11 +653,9 @@ const styles = StyleSheet.create({
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
hrvTestButton: {
|
||||
backgroundColor: '#8B5CF6',
|
||||
},
|
||||
debugButtonText: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -657,13 +689,15 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
lineHeight: 18,
|
||||
fontWeight: '600',
|
||||
textAlignVertical: 'bottom'
|
||||
textAlignVertical: 'bottom',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
caloriesUnit: {
|
||||
color: '#515558ff',
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
trainingContent: {
|
||||
marginTop: 8,
|
||||
@@ -697,6 +731,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#8B74F3',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
cyclingHeader: {
|
||||
flexDirection: 'row',
|
||||
@@ -716,6 +751,7 @@ const styles = StyleSheet.create({
|
||||
color: '#FFFFFF',
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
mapArea: {
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
@@ -755,6 +791,7 @@ const styles = StyleSheet.create({
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
heartCard: {
|
||||
backgroundColor: '#FFE5E5',
|
||||
@@ -775,12 +812,14 @@ const styles = StyleSheet.create({
|
||||
alignSelf: 'flex-end',
|
||||
color: '#5B5B5B',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
stepsValue: {
|
||||
fontSize: 14,
|
||||
color: '#7A6A42',
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -796,6 +835,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
retryButton: {
|
||||
padding: 4,
|
||||
@@ -810,11 +850,13 @@ const styles = StyleSheet.create({
|
||||
viewMoreText: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
viewMoreIcon: {
|
||||
fontSize: 16,
|
||||
color: '#192126',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
stressCardRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -885,6 +927,7 @@ const styles = StyleSheet.create({
|
||||
color: '#0369A1',
|
||||
fontWeight: '800',
|
||||
marginTop: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
addWeightButton: {
|
||||
position: 'absolute',
|
||||
@@ -905,6 +948,54 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
textAlign: 'left',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
reportButton: {
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: '#F6F7FB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
reportIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reportButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#0F172A',
|
||||
},
|
||||
// Liquid Glass 风格按钮
|
||||
liquidGlassButton: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
liquidGlassFallback: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
|
||||
|
||||
|
||||
210
app/_layout.tsx
210
app/_layout.tsx
@@ -1,15 +1,9 @@
|
||||
import '@/i18n';
|
||||
import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import PrivacyConsentModal from '@/components/PrivacyConsentModal';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useQuickActions } from '@/hooks/useQuickActions';
|
||||
import '@/i18n';
|
||||
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,17 +17,26 @@ 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 { DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { DialogProvider } from '@/components/ui/DialogProvider';
|
||||
import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import { VersionCheckProvider } from '@/contexts/VersionCheckContext';
|
||||
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('🎉 空闲服务初始化完成');
|
||||
@@ -440,6 +484,51 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
||||
getPrivacyAgreed();
|
||||
}, []);
|
||||
|
||||
// 使用 ref 确保更新检查只执行一次
|
||||
// const updateCheckRequestedRef = React.useRef(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// // 如果已经执行过更新检查,直接返回
|
||||
// if (updateCheckRequestedRef.current) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// updateCheckRequestedRef.current = true;
|
||||
|
||||
// async function checkUpdate() {
|
||||
// try {
|
||||
// logger.info(`Checking for updates..., env: ${__DEV__}, Updates.isEnabled: ${Updates.isEnabled}`);
|
||||
|
||||
// // 只有在 expo-updates 启用时才检查更新
|
||||
// // 开发环境或 Updates 未启用时跳过
|
||||
// if (__DEV__ || !Updates.isEnabled) {
|
||||
// logger.info('Skipping update check: dev mode or updates not enabled');
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const update = await Updates.checkForUpdateAsync();
|
||||
// logger.info("Update check:", update);
|
||||
|
||||
// if (update.isAvailable) {
|
||||
// logger.info("Update available, fetching...");
|
||||
// const result = await Updates.fetchUpdateAsync();
|
||||
// logger.info("Fetch result:", result);
|
||||
|
||||
// if (result.isNew) {
|
||||
// logger.info("Reloading app to apply update...");
|
||||
// await Updates.reloadAsync();
|
||||
// }
|
||||
// }
|
||||
// } catch (e) {
|
||||
// logger.error("Update error:", e);
|
||||
// }
|
||||
// }
|
||||
|
||||
// setTimeout(() => {
|
||||
// checkUpdate();
|
||||
// }, 5000);
|
||||
// }, []);
|
||||
|
||||
const handlePrivacyAgree = () => {
|
||||
dispatch(setPrivacyAgreed());
|
||||
setShowPrivacyModal(false);
|
||||
@@ -466,6 +555,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) {
|
||||
@@ -478,33 +569,32 @@ export default function RootLayout() {
|
||||
<Provider store={store}>
|
||||
<Bootstrapper>
|
||||
<ToastProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="challenge" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="training-plan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="workout" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="profile/edit" />
|
||||
<Stack.Screen name="fasting/[planId]" options={{ headerShown: false }} />
|
||||
<VersionCheckProvider>
|
||||
<ThemeProvider value={DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="onboarding" />
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<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="badges/index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<StatusBar style="dark" />
|
||||
</ThemeProvider>
|
||||
<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="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" />
|
||||
</ThemeProvider>
|
||||
</VersionCheckProvider>
|
||||
</ToastProvider>
|
||||
</Bootstrapper>
|
||||
</Provider>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { fetchMyProfile, login } from '@/store/userSlice';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function LoginScreen() {
|
||||
const color = Colors[scheme];
|
||||
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useI18n();
|
||||
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
|
||||
|
||||
// 背景动效:轻微平移/旋转与呼吸动画
|
||||
@@ -79,12 +81,12 @@ export default function LoginScreen() {
|
||||
const guardAgreement = useCallback((action: () => void) => {
|
||||
if (!hasAgreed) {
|
||||
Alert.alert(
|
||||
'请先阅读并同意',
|
||||
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
|
||||
t('login.agreement.alert.title'),
|
||||
t('login.agreement.alert.message'),
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: t('login.agreement.alert.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: '同意并继续',
|
||||
text: t('login.agreement.alert.confirm'),
|
||||
onPress: () => {
|
||||
setHasAgreed(true);
|
||||
setTimeout(() => action(), 0);
|
||||
@@ -96,7 +98,7 @@ export default function LoginScreen() {
|
||||
return;
|
||||
}
|
||||
action();
|
||||
}, [hasAgreed]);
|
||||
}, [hasAgreed, t]);
|
||||
|
||||
const onAppleLogin = useCallback(async () => {
|
||||
if (!appleAvailable) return;
|
||||
@@ -110,7 +112,7 @@ export default function LoginScreen() {
|
||||
});
|
||||
const identityToken = (credential as any)?.identityToken;
|
||||
if (!identityToken || typeof identityToken !== 'string') {
|
||||
throw new Error('未获取到 Apple 身份令牌');
|
||||
throw new Error(t('login.errors.appleIdentityTokenMissing'));
|
||||
}
|
||||
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
|
||||
|
||||
@@ -118,7 +120,7 @@ export default function LoginScreen() {
|
||||
await dispatch(fetchMyProfile())
|
||||
|
||||
Toast.show({
|
||||
text1: '登录成功',
|
||||
text1: t('login.success.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
// 登录成功后处理重定向
|
||||
@@ -145,12 +147,12 @@ export default function LoginScreen() {
|
||||
console.log('err.code', err.code);
|
||||
|
||||
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
|
||||
const message = err?.message || '登录失败,请稍后再试';
|
||||
Alert.alert('登录失败', message);
|
||||
const message = err?.message || t('login.errors.loginFailed');
|
||||
Alert.alert(t('login.errors.loginFailedTitle'), message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo, dispatch, t]);
|
||||
|
||||
|
||||
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
|
||||
@@ -244,14 +246,14 @@ export default function LoginScreen() {
|
||||
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={[styles.headerTitle, { color: color.text }]}>登录</Text>
|
||||
<Text style={[styles.headerTitle, { color: color.text }]}>{t('login.title')}</Text>
|
||||
<View style={{ width: 32 }} />
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerWrap}>
|
||||
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>健康生活,自律让我更自由</ThemedText>
|
||||
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>{t('login.subtitle')}</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Apple 登录 */}
|
||||
@@ -276,12 +278,12 @@ export default function LoginScreen() {
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text style={styles.appleText}>登录中...</Text>
|
||||
<Text style={styles.appleText}>{t('login.loggingIn')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
@@ -294,12 +296,12 @@ export default function LoginScreen() {
|
||||
color="#FFFFFF"
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
<Text style={styles.appleText}>登录中...</Text>
|
||||
<Text style={styles.appleText}>{t('login.loggingIn')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
|
||||
<Text style={styles.appleText}>使用 Apple 登录</Text>
|
||||
<Text style={styles.appleText}>{t('login.appleLogin')}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -319,13 +321,13 @@ export default function LoginScreen() {
|
||||
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>我已阅读并同意</Text>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.readAndAgree')}</Text>
|
||||
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
|
||||
<Text style={[styles.link, { color: color.primary }]}>《隐私政策》</Text>
|
||||
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.privacyPolicy')}</Text>
|
||||
</Pressable>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>和</Text>
|
||||
<Text style={[styles.agreementText, { color: color.textMuted }]}>{t('login.agreement.and')}</Text>
|
||||
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
|
||||
<Text style={[styles.link, { color: color.primary }]}>《用户协议》</Text>
|
||||
<Text style={[styles.link, { color: color.primary }]}>{t('login.agreement.userAgreement')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { selectUserAge, selectUserProfile } from '@/store/userSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { getLocalizedDateFormat, getMonthDays, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -24,6 +25,7 @@ type BasalMetabolismData = {
|
||||
};
|
||||
|
||||
export default function BasalMetabolismDetailScreen() {
|
||||
const { t, i18n } = useI18n();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const userAge = useAppSelector(selectUserAge);
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
@@ -140,9 +142,9 @@ export default function BasalMetabolismDetailScreen() {
|
||||
|
||||
// 获取当前选中日期
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
const days = getMonthDaysZh();
|
||||
const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}, [selectedIndex]);
|
||||
}, [selectedIndex, i18n.language]);
|
||||
|
||||
|
||||
// 计算BMR范围
|
||||
@@ -203,7 +205,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
setSelectedIndex(index);
|
||||
|
||||
// 获取选中日期
|
||||
const days = getMonthDaysZh();
|
||||
const days = getMonthDays(undefined, i18n.language as 'zh' | 'en');
|
||||
const selectedDate = days[index]?.date?.toDate();
|
||||
|
||||
if (selectedDate) {
|
||||
@@ -247,7 +249,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
@@ -280,7 +282,8 @@ export default function BasalMetabolismDetailScreen() {
|
||||
// 显示周数
|
||||
const weekOfYear = dayjs(item.date).week();
|
||||
const firstWeekOfYear = dayjs(item.date).startOf('year').week();
|
||||
return `第${weekOfYear - firstWeekOfYear + 1}周`;
|
||||
const weekNumber = weekOfYear - firstWeekOfYear + 1;
|
||||
return t('basalMetabolismDetail.chart.weekLabel', { week: weekNumber });
|
||||
default:
|
||||
return dayjs(item.date).format('MM-DD');
|
||||
}
|
||||
@@ -319,7 +322,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
|
||||
{/* 头部导航 */}
|
||||
<HeaderBar
|
||||
title="基础代谢"
|
||||
title={t('basalMetabolismDetail.title')}
|
||||
transparent
|
||||
right={
|
||||
<TouchableOpacity
|
||||
@@ -355,7 +358,9 @@ export default function BasalMetabolismDetailScreen() {
|
||||
{/* 当前日期基础代谢显示 */}
|
||||
<View style={styles.currentDataCard}>
|
||||
<Text style={styles.currentDataTitle}>
|
||||
{dayjs(currentSelectedDate).format('M月D日')} 基础代谢
|
||||
{t('basalMetabolismDetail.currentData.title', {
|
||||
date: getLocalizedDateFormat(dayjs(currentSelectedDate), i18n.language as 'zh' | 'en')
|
||||
})}
|
||||
</Text>
|
||||
<View style={styles.currentValueContainer}>
|
||||
<Text style={styles.currentValue}>
|
||||
@@ -366,21 +371,24 @@ export default function BasalMetabolismDetailScreen() {
|
||||
if (selectedDateData?.value) {
|
||||
return Math.round(selectedDateData.value).toString();
|
||||
}
|
||||
return '--';
|
||||
return t('basalMetabolismDetail.currentData.noData');
|
||||
})()}
|
||||
</Text>
|
||||
<Text style={styles.currentUnit}>千卡</Text>
|
||||
<Text style={styles.currentUnit}>{t('basalMetabolismDetail.currentData.unit')}</Text>
|
||||
</View>
|
||||
{bmrRange && (
|
||||
<Text style={styles.rangeText}>
|
||||
正常范围: {bmrRange.min}-{bmrRange.max} 千卡
|
||||
{t('basalMetabolismDetail.currentData.normalRange', {
|
||||
min: bmrRange.min,
|
||||
max: bmrRange.max
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 基础代谢统计 */}
|
||||
<View style={styles.statsCard}>
|
||||
<Text style={styles.statsTitle}>基础代谢统计</Text>
|
||||
<Text style={styles.statsTitle}>{t('basalMetabolismDetail.stats.title')}</Text>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View style={styles.tabContainer}>
|
||||
@@ -390,7 +398,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
|
||||
按周
|
||||
{t('basalMetabolismDetail.stats.tabs.week')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -399,7 +407,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||
按月
|
||||
{t('basalMetabolismDetail.stats.tabs.month')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -408,28 +416,30 @@ export default function BasalMetabolismDetailScreen() {
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingChart}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
<Text style={styles.loadingText}>{t('basalMetabolismDetail.chart.loadingText')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorChart}>
|
||||
<Text style={styles.errorText}>加载失败: {error}</Text>
|
||||
<Text style={styles.errorText}>
|
||||
{t('basalMetabolismDetail.chart.error.text', { error })}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
// 重新加载数据
|
||||
// {t('basalMetabolismDetail.comments.reloadData')}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchBasalMetabolismData(activeTab).then(data => {
|
||||
setChartData(data);
|
||||
setIsLoading(false);
|
||||
}).catch(err => {
|
||||
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||
setError(err instanceof Error ? err.message : t('basalMetabolismDetail.chart.error.fetchFailed'));
|
||||
setIsLoading(false);
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
<Text style={styles.retryText}>{t('basalMetabolismDetail.chart.error.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : processedChartData.datasets.length > 0 && processedChartData.datasets[0].data.length > 0 ? (
|
||||
@@ -441,7 +451,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
width={Dimensions.get('window').width - 80}
|
||||
height={220}
|
||||
yAxisLabel=""
|
||||
yAxisSuffix="千卡"
|
||||
yAxisSuffix={t('basalMetabolismDetail.chart.yAxisSuffix')}
|
||||
chartConfig={{
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundGradientFrom: '#ffffff',
|
||||
@@ -470,7 +480,7 @@ export default function BasalMetabolismDetailScreen() {
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyChart}>
|
||||
<Text style={styles.emptyChartText}>暂无数据</Text>
|
||||
<Text style={styles.emptyChartText}>{t('basalMetabolismDetail.chart.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -490,56 +500,66 @@ export default function BasalMetabolismDetailScreen() {
|
||||
style={styles.closeButton}
|
||||
onPress={() => setInfoModalVisible(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>×</Text>
|
||||
<Text style={styles.closeButtonText}>{t('basalMetabolismDetail.modal.closeButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 标题 */}
|
||||
<Text style={styles.modalTitle}>基础代谢</Text>
|
||||
<Text style={styles.modalTitle}>{t('basalMetabolismDetail.modal.title')}</Text>
|
||||
|
||||
{/* 基础代谢定义 */}
|
||||
<Text style={styles.modalDescription}>
|
||||
基础代谢,也称基础代谢率(BMR),是指人体在完全静息状态下维持基本生命功能(心跳、呼吸、体温调节等)所需的最低能量消耗,通常以卡路里为单位。
|
||||
{t('basalMetabolismDetail.modal.description')}
|
||||
</Text>
|
||||
|
||||
{/* 为什么重要 */}
|
||||
<Text style={styles.sectionTitle}>为什么重要?</Text>
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.importance.title')}</Text>
|
||||
<Text style={styles.sectionContent}>
|
||||
基础代谢占总能量消耗的60-75%,是能量平衡的基础。了解您的基础代谢有助于制定科学的营养计划、优化体重管理策略,以及评估代谢健康状态。
|
||||
{t('basalMetabolismDetail.modal.sections.importance.content')}
|
||||
</Text>
|
||||
|
||||
{/* 正常范围 */}
|
||||
<Text style={styles.sectionTitle}>正常范围</Text>
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.normalRange.title')}</Text>
|
||||
<Text style={styles.formulaText}>
|
||||
- 男性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 + 5
|
||||
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.male')}
|
||||
</Text>
|
||||
<Text style={styles.formulaText}>
|
||||
- 女性:BMR = 10 × 体重(kg) + 6.25 × 身高(cm) - 5 × 年龄 - 161
|
||||
- {t('basalMetabolismDetail.modal.sections.normalRange.formulas.female')}
|
||||
</Text>
|
||||
|
||||
{bmrRange ? (
|
||||
<>
|
||||
<Text style={styles.rangeText}>您的正常区间:{bmrRange.min}-{bmrRange.max}千卡/天</Text>
|
||||
<Text style={styles.rangeText}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.userRange', {
|
||||
min: bmrRange.min,
|
||||
max: bmrRange.max
|
||||
})}
|
||||
</Text>
|
||||
<Text style={styles.rangeNote}>
|
||||
(在公式基础计算值上下浮动15%都属于正常范围)
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.rangeNote')}
|
||||
</Text>
|
||||
<Text style={styles.userInfoText}>
|
||||
基于您的信息:{userProfile.gender === 'male' ? '男性' : '女性'},{userAge}岁,{userProfile.height}cm,{userProfile.weight}kg
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.userInfo', {
|
||||
gender: t(`basalMetabolismDetail.gender.${userProfile.gender === 'male' ? 'male' : 'female'}`),
|
||||
age: userAge,
|
||||
height: userProfile.height,
|
||||
weight: userProfile.weight
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.rangeText}>请完善基本信息以计算您的代谢率</Text>
|
||||
<Text style={styles.rangeText}>
|
||||
{t('basalMetabolismDetail.modal.sections.normalRange.incompleteInfo')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 提高代谢率的策略 */}
|
||||
<Text style={styles.sectionTitle}>提高代谢率的策略</Text>
|
||||
<Text style={styles.strategyText}>科学研究支持以下方法:</Text>
|
||||
<Text style={styles.sectionTitle}>{t('basalMetabolismDetail.modal.sections.strategies.title')}</Text>
|
||||
<Text style={styles.strategyText}>{t('basalMetabolismDetail.modal.sections.strategies.subtitle')}</Text>
|
||||
|
||||
<View style={styles.strategyList}>
|
||||
<Text style={styles.strategyItem}>1.增加肌肉量 (每周2-3次力量训练)</Text>
|
||||
<Text style={styles.strategyItem}>2.高强度间歇训练 (HIIT)</Text>
|
||||
<Text style={styles.strategyItem}>3.充分蛋白质摄入 (体重每公斤1.6-2.2g)</Text>
|
||||
<Text style={styles.strategyItem}>4.保证充足睡眠 (7-9小时/晚)</Text>
|
||||
<Text style={styles.strategyItem}>5.避免过度热量限制 (不低于BMR的80%)</Text>
|
||||
{(t('basalMetabolismDetail.modal.sections.strategies.items', { returnObjects: true }) as string[]).map((item: string, index: number) => (
|
||||
<Text key={index} style={styles.strategyItem}>{item}</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
fetchChallengeDetail,
|
||||
@@ -37,6 +38,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useI18n();
|
||||
|
||||
const challengeSelector = useMemo(() => (id ? selectChallengeById(id) : undefined), [id]);
|
||||
const challenge = useAppSelector((state) => (challengeSelector ? challengeSelector(state) : undefined));
|
||||
@@ -75,12 +77,12 @@ export default function ChallengeLeaderboardScreen() {
|
||||
if (!id) {
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>未找到该挑战。</Text>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.notFound')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -89,10 +91,10 @@ export default function ChallengeLeaderboardScreen() {
|
||||
if (detailStatus === 'loading' && !challenge) {
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>加载榜单中…</Text>
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -131,10 +133,10 @@ export default function ChallengeLeaderboardScreen() {
|
||||
if (!challenge) {
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<View style={styles.missingContainer}>
|
||||
<Text style={[styles.missingText, { color: colorTokens.textSecondary }]}>
|
||||
{detailError ?? '暂时无法加载榜单,请稍后再试。'}
|
||||
{detailError ?? t('challengeDetail.leaderboard.loadFailed')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -146,7 +148,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
|
||||
return (
|
||||
<View style={[styles.safeArea, { backgroundColor: '#f3f4fb' }]}>
|
||||
<HeaderBar title="排行榜" onBack={() => router.back()} withSafeTop />
|
||||
<HeaderBar title={t('challengeDetail.leaderboard.title')} onBack={() => router.back()} withSafeTop />
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 40, paddingTop: safeAreaTop }}
|
||||
@@ -178,7 +180,7 @@ export default function ChallengeLeaderboardScreen() {
|
||||
{showInitialRankingLoading ? (
|
||||
<View style={styles.rankingLoading}>
|
||||
<ActivityIndicator color={colorTokens.primary} />
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>加载榜单中…</Text>
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary }]}>{t('challengeDetail.leaderboard.loading')}</Text>
|
||||
</View>
|
||||
) : rankingData.length ? (
|
||||
rankingData.map((item, index) => (
|
||||
@@ -196,18 +198,18 @@ export default function ChallengeLeaderboardScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyRanking}>
|
||||
<Text style={styles.emptyRankingText}>榜单即将开启,快来抢占席位。</Text>
|
||||
<Text style={styles.emptyRankingText}>{t('challengeDetail.leaderboard.empty')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isLoadingMore ? (
|
||||
<View style={styles.loadMoreIndicator}>
|
||||
<ActivityIndicator color={colorTokens.primary} size="small" />
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>加载更多…</Text>
|
||||
<Text style={[styles.loadingText, { color: colorTokens.textSecondary, marginTop: 8 }]}>{t('challengeDetail.leaderboard.loadMore')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{rankingLoadMoreStatus === 'failed' ? (
|
||||
<View style={styles.loadMoreIndicator}>
|
||||
<Text style={styles.loadMoreErrorText}>加载更多失败,请下拉刷新重试</Text>
|
||||
<Text style={styles.loadMoreErrorText}>{t('challengeDetail.leaderboard.loadMoreFailed')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
1083
app/challenges/create-custom.tsx
Normal file
1083
app/challenges/create-custom.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ const CIRCUMFERENCE_TYPES = [
|
||||
{ key: 'calfCircumference', label: '小腿围', color: '#DDA0DD' },
|
||||
];
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { CircumferencePeriod } from '@/services/circumferenceAnalysis';
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function CircumferenceDetailScreen() {
|
||||
const dispatch = useAppDispatch();
|
||||
const userProfile = useAppSelector(selectUserProfile);
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { t } = useI18n();
|
||||
|
||||
// 日期相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -78,37 +80,37 @@ export default function CircumferenceDetailScreen() {
|
||||
const measurements = [
|
||||
{
|
||||
key: 'chestCircumference',
|
||||
label: '胸围',
|
||||
label: t('circumferenceDetail.measurements.chest'),
|
||||
value: userProfile?.chestCircumference,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
{
|
||||
key: 'waistCircumference',
|
||||
label: '腰围',
|
||||
label: t('circumferenceDetail.measurements.waist'),
|
||||
value: userProfile?.waistCircumference,
|
||||
color: '#4ECDC4',
|
||||
},
|
||||
{
|
||||
key: 'upperHipCircumference',
|
||||
label: '上臀围',
|
||||
label: t('circumferenceDetail.measurements.upperHip'),
|
||||
value: userProfile?.upperHipCircumference,
|
||||
color: '#45B7D1',
|
||||
},
|
||||
{
|
||||
key: 'armCircumference',
|
||||
label: '臂围',
|
||||
label: t('circumferenceDetail.measurements.arm'),
|
||||
value: userProfile?.armCircumference,
|
||||
color: '#96CEB4',
|
||||
},
|
||||
{
|
||||
key: 'thighCircumference',
|
||||
label: '大腿围',
|
||||
label: t('circumferenceDetail.measurements.thigh'),
|
||||
value: userProfile?.thighCircumference,
|
||||
color: '#FFEAA7',
|
||||
},
|
||||
{
|
||||
key: 'calfCircumference',
|
||||
label: '小腿围',
|
||||
label: t('circumferenceDetail.measurements.calf'),
|
||||
value: userProfile?.calfCircumference,
|
||||
color: '#DDA0DD',
|
||||
},
|
||||
@@ -243,10 +245,10 @@ export default function CircumferenceDetailScreen() {
|
||||
// 将YYYY-MM-DD格式转换为第几周
|
||||
const weekOfYear = dayjs(item.label).week();
|
||||
const firstWeekOfMonth = dayjs(item.label).startOf('month').week();
|
||||
return `第${weekOfYear - firstWeekOfMonth + 1}周`;
|
||||
return t('circumferenceDetail.chart.weekLabel', { week: weekOfYear - firstWeekOfMonth + 1 });
|
||||
case 'year':
|
||||
// 将YYYY-MM格式转换为月份
|
||||
return dayjs(item.label).format('M月');
|
||||
return t('circumferenceDetail.chart.monthLabel', { month: dayjs(item.label).format('M') });
|
||||
default:
|
||||
return item.label;
|
||||
}
|
||||
@@ -287,7 +289,7 @@ export default function CircumferenceDetailScreen() {
|
||||
|
||||
{/* 头部导航 */}
|
||||
<HeaderBar
|
||||
title="围度统计"
|
||||
title={t('circumferenceDetail.title')}
|
||||
transparent
|
||||
/>
|
||||
|
||||
@@ -338,7 +340,7 @@ export default function CircumferenceDetailScreen() {
|
||||
|
||||
{/* 围度统计 */}
|
||||
<View style={styles.statsCard}>
|
||||
<Text style={styles.statsTitle}>围度统计</Text>
|
||||
<Text style={styles.statsTitle}>{t('circumferenceDetail.title')}</Text>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<View style={styles.tabContainer}>
|
||||
@@ -348,7 +350,7 @@ export default function CircumferenceDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'week' && styles.activeTabText]}>
|
||||
按周
|
||||
{t('circumferenceDetail.tabs.week')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -357,7 +359,7 @@ export default function CircumferenceDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'month' && styles.activeTabText]}>
|
||||
按月
|
||||
{t('circumferenceDetail.tabs.month')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -366,7 +368,7 @@ export default function CircumferenceDetailScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.tabText, activeTab === 'year' && styles.activeTabText]}>
|
||||
按年
|
||||
{t('circumferenceDetail.tabs.year')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -390,7 +392,7 @@ export default function CircumferenceDetailScreen() {
|
||||
styles.legendText,
|
||||
!isVisible && styles.legendTextHidden
|
||||
]}>
|
||||
{type.label}
|
||||
{t(`circumferenceDetail.measurements.${type.key.replace('Circumference', '')}`)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -401,17 +403,17 @@ export default function CircumferenceDetailScreen() {
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingChart}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
<Text style={styles.loadingText}>{t('circumferenceDetail.loading')}</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorChart}>
|
||||
<Text style={styles.errorText}>加载失败: {error}</Text>
|
||||
<Text style={styles.errorText}>{t('circumferenceDetail.error')}: {error}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => dispatch(fetchCircumferenceAnalysis(activeTab))}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
<Text style={styles.retryText}>{t('circumferenceDetail.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : processedChartData.datasets.length > 0 ? (
|
||||
@@ -453,8 +455,8 @@ export default function CircumferenceDetailScreen() {
|
||||
<View style={styles.emptyChart}>
|
||||
<Text style={styles.emptyChartText}>
|
||||
{processedChartData.datasets.length === 0 && !isLoading && !error
|
||||
? '暂无数据'
|
||||
: '请选择要显示的围度数据'
|
||||
? t('circumferenceDetail.chart.empty')
|
||||
: t('circumferenceDetail.chart.noSelection')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -469,12 +471,12 @@ export default function CircumferenceDetailScreen() {
|
||||
setModalVisible(false);
|
||||
setSelectedMeasurement(null);
|
||||
}}
|
||||
title={selectedMeasurement ? `设置${selectedMeasurement.label}` : '设置围度'}
|
||||
title={selectedMeasurement ? t('circumferenceDetail.modal.title', { label: selectedMeasurement.label }) : t('circumferenceDetail.modal.defaultTitle')}
|
||||
items={circumferenceOptions}
|
||||
selectedValue={selectedMeasurement?.currentValue}
|
||||
onValueChange={() => { }} // Real-time update not needed
|
||||
onConfirm={handleUpdateMeasurement}
|
||||
confirmButtonText="确认"
|
||||
confirmButtonText={t('circumferenceDetail.modal.confirm')}
|
||||
pickerHeight={180}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ThemedView } from '@/components/ThemedView';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
fetchActivityRingsForDate,
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import 'dayjs/locale/en';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
// 配置 dayjs 插件
|
||||
dayjs.extend(utc);
|
||||
@@ -51,7 +54,8 @@ type WeekData = {
|
||||
};
|
||||
|
||||
export default function FitnessRingsDetailScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const { t, i18n } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const colorScheme = useColorScheme();
|
||||
const [weekData, setWeekData] = useState<WeekData[]>([]);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
@@ -82,7 +86,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
exerciseInfoAnim.setValue(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载锻炼分钟说明偏好失败:', error);
|
||||
console.error(t('fitnessRingsDetail.errors.loadExerciseInfoPreference'), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,7 +102,15 @@ export default function FitnessRingsDetailScreen() {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const currentDay = startOfWeek.add(i, 'day');
|
||||
const isToday = currentDay.isSame(today, 'day');
|
||||
const dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
const dayNames = [
|
||||
t('fitnessRingsDetail.weekDays.monday'),
|
||||
t('fitnessRingsDetail.weekDays.tuesday'),
|
||||
t('fitnessRingsDetail.weekDays.wednesday'),
|
||||
t('fitnessRingsDetail.weekDays.thursday'),
|
||||
t('fitnessRingsDetail.weekDays.friday'),
|
||||
t('fitnessRingsDetail.weekDays.saturday'),
|
||||
t('fitnessRingsDetail.weekDays.sunday')
|
||||
];
|
||||
|
||||
try {
|
||||
const activityRingsData = await fetchActivityRingsForDate(currentDay.toDate());
|
||||
@@ -164,8 +176,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
|
||||
// 格式化头部显示的日期
|
||||
const formatHeaderDate = (date: Date) => {
|
||||
const dayJsDate = dayjs(date).tz('Asia/Shanghai');
|
||||
return `${dayJsDate.format('YYYY年MM月DD日')}`;
|
||||
const dayJsDate = dayjs(date).tz('Asia/Shanghai').locale(i18n.language === 'zh' ? 'zh-cn' : 'en');
|
||||
const dateFormat = t('fitnessRingsDetail.dateFormats.header', { defaultValue: 'YYYY年MM月DD日' });
|
||||
return dayJsDate.format(dateFormat);
|
||||
};
|
||||
|
||||
const renderWeekRingItem = (item: WeekData, index: number) => {
|
||||
@@ -303,7 +316,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
setShowExerciseInfo(false);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存锻炼分钟说明偏好失败:', error);
|
||||
console.error(t('fitnessRingsDetail.errors.saveExerciseInfoPreference'), error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,7 +393,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 活动热量卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>活动热量</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.activeCalories.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -390,25 +403,25 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#FF3B30' }]}>
|
||||
{Math.round(activeEnergyBurned)}/{activeEnergyBurnedGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>千卡</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.activeCalories.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(activeEnergyBurned)}千卡
|
||||
{Math.round(activeEnergyBurned)}{t('fitnessRingsDetail.cards.activeCalories.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyCaloriesData.map(h => h.calories),
|
||||
Math.max(activeEnergyBurnedGoal / 24, 1),
|
||||
'#FF3B30',
|
||||
'千卡'
|
||||
t('fitnessRingsDetail.cards.activeCalories.unit')
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 锻炼分钟卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>锻炼分钟数</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -418,18 +431,18 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#FF9500' }]}>
|
||||
{Math.round(appleExerciseTime)}/{appleExerciseTimeGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>分钟</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleExerciseTime)}分钟
|
||||
{Math.round(appleExerciseTime)}{t('fitnessRingsDetail.cards.exerciseMinutes.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyExerciseData.map(h => h.minutes),
|
||||
Math.max(appleExerciseTimeGoal / 8, 1),
|
||||
'#FF9500',
|
||||
'分钟'
|
||||
t('fitnessRingsDetail.cards.exerciseMinutes.unit')
|
||||
)}
|
||||
|
||||
{/* 锻炼分钟说明 */}
|
||||
@@ -450,15 +463,15 @@ export default function FitnessRingsDetailScreen() {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={styles.exerciseTitle}>锻炼分钟数:</Text>
|
||||
<Text style={styles.exerciseTitle}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.title')}</Text>
|
||||
<Text style={styles.exerciseDesc}>
|
||||
进行强度不低于"快走"的运动锻炼,就会积累对应时长的锻炼分钟数。
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.description')}
|
||||
</Text>
|
||||
<Text style={styles.exerciseRecommendation}>
|
||||
世卫组织推荐的成年人每天至少保持30分钟以上的中高强度运动。
|
||||
{t('fitnessRingsDetail.cards.exerciseMinutes.info.recommendation')}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.knowButton} onPress={handleKnowButtonPress}>
|
||||
<Text style={styles.knowButtonText}>知道了</Text>
|
||||
<Text style={styles.knowButtonText}>{t('fitnessRingsDetail.cards.exerciseMinutes.info.knowButton')}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -467,7 +480,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 活动小时数卡片 */}
|
||||
<View style={styles.metricCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.cardTitle}>活动小时数</Text>
|
||||
<Text style={styles.cardTitle}>{t('fitnessRingsDetail.cards.standHours.title')}</Text>
|
||||
<TouchableOpacity style={styles.helpButton}>
|
||||
<Text style={styles.helpIcon}>?</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -477,18 +490,18 @@ export default function FitnessRingsDetailScreen() {
|
||||
<Text style={[styles.valueText, { color: '#007AFF' }]}>
|
||||
{Math.round(appleStandHours)}/{appleStandHoursGoal}
|
||||
</Text>
|
||||
<Text style={styles.unitText}>小时</Text>
|
||||
<Text style={styles.unitText}>{t('fitnessRingsDetail.cards.standHours.unit')}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardSubtext}>
|
||||
{Math.round(appleStandHours)}小时
|
||||
{Math.round(appleStandHours)}{t('fitnessRingsDetail.cards.standHours.unit')}
|
||||
</Text>
|
||||
|
||||
{renderBarChart(
|
||||
hourlyStandData.map(h => h.hasStood),
|
||||
1,
|
||||
'#007AFF',
|
||||
'小时'
|
||||
t('fitnessRingsDetail.cards.standHours.unit')
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -536,9 +549,9 @@ export default function FitnessRingsDetailScreen() {
|
||||
{/* 周闭环天数统计 */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statRow}>
|
||||
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>周闭环天数</Text>
|
||||
<Text style={[styles.statLabel, { color: Colors[colorScheme ?? 'light'].text }]}>{t('fitnessRingsDetail.stats.weeklyClosedRings')}</Text>
|
||||
<View style={styles.statValue}>
|
||||
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}天</Text>
|
||||
<Text style={[styles.statNumber, { color: Colors[colorScheme ?? 'light'].text }]}>{getClosedRingCount()}{t('fitnessRingsDetail.stats.daysUnit')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -559,7 +572,7 @@ export default function FitnessRingsDetailScreen() {
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={new Date(2020, 0, 1)}
|
||||
maximumDate={new Date()}
|
||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
@@ -575,12 +588,12 @@ export default function FitnessRingsDetailScreen() {
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('fitnessRingsDetail.datePicker.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('fitnessRingsDetail.datePicker.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
@@ -874,4 +887,4 @@ const styles = StyleSheet.create({
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { addDietRecord, type CreateDietRecordDto, type MealType } from '@/services/dietRecords';
|
||||
import { selectFoodRecognitionResult } from '@/store/foodRecognitionSlice';
|
||||
@@ -65,15 +66,8 @@ const mockFoodItems = [
|
||||
}
|
||||
];
|
||||
|
||||
// 餐次映射
|
||||
const MEAL_TYPE_MAP = {
|
||||
breakfast: '早餐',
|
||||
lunch: '午餐',
|
||||
dinner: '晚餐',
|
||||
snack: '加餐'
|
||||
};
|
||||
|
||||
export default function FoodAnalysisResultScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
@@ -190,6 +184,15 @@ export default function FoodAnalysisResultScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// 餐次映射
|
||||
const MEAL_TYPE_MAP = {
|
||||
breakfast: t('nutritionRecords.mealTypes.breakfast'),
|
||||
lunch: t('nutritionRecords.mealTypes.lunch'),
|
||||
dinner: t('nutritionRecords.mealTypes.dinner'),
|
||||
snack: t('nutritionRecords.mealTypes.snack'),
|
||||
other: t('nutritionRecords.mealTypes.other'),
|
||||
};
|
||||
|
||||
// 计算所有食物的总营养数据
|
||||
const totalCalories = foodItems.reduce((sum, item) => sum + item.calories, 0);
|
||||
const totalProtein = foodItems.reduce((sum, item) => sum + item.protein, 0);
|
||||
@@ -253,24 +256,24 @@ export default function FoodAnalysisResultScreen() {
|
||||
|
||||
// 餐次选择选项
|
||||
const mealOptions = [
|
||||
{ key: 'breakfast' as const, label: '早餐', color: '#FF6B35' },
|
||||
{ key: 'lunch' as const, label: '午餐', color: '#4CAF50' },
|
||||
{ key: 'dinner' as const, label: '晚餐', color: '#2196F3' },
|
||||
{ key: 'snack' as const, label: '加餐', color: '#FF9800' },
|
||||
{ key: 'breakfast' as const, label: t('nutritionRecords.mealTypes.breakfast'), color: '#FF6B35' },
|
||||
{ key: 'lunch' as const, label: t('nutritionRecords.mealTypes.lunch'), color: '#4CAF50' },
|
||||
{ key: 'dinner' as const, label: t('nutritionRecords.mealTypes.dinner'), color: '#2196F3' },
|
||||
{ key: 'snack' as const, label: t('nutritionRecords.mealTypes.snack'), color: '#FF9800' },
|
||||
];
|
||||
|
||||
if (!imageUri && !recognitionResult) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
title={t('foodAnalysisResult.title')}
|
||||
onBack={() => router.back()}
|
||||
/>
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}} />
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>未找到图片或识别结果</Text>
|
||||
<Text style={styles.errorText}>{t('foodAnalysisResult.error.notFound')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -287,7 +290,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="分析结果"
|
||||
title={t('foodAnalysisResult.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
@@ -316,7 +319,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.placeholderContainer}>
|
||||
<View style={styles.placeholderContent}>
|
||||
<Ionicons name="restaurant-outline" size={48} color="#666" />
|
||||
<Text style={styles.placeholderText}>营养记录</Text>
|
||||
<Text style={styles.placeholderText}>{t('foodAnalysisResult.placeholder')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -325,8 +328,8 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.descriptionBubble}>
|
||||
<Text style={styles.descriptionText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
dayjs().format('YYYY年M月D日')
|
||||
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
|
||||
dayjs().format(t('foodAnalysisResult.dateFormats.today'))
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -337,31 +340,31 @@ export default function FoodAnalysisResultScreen() {
|
||||
{/* 卡路里 */}
|
||||
<View style={styles.calorieSection}>
|
||||
<Text style={styles.calorieValue}>{totalCalories}</Text>
|
||||
<Text style={styles.calorieUnit}>千卡</Text>
|
||||
<Text style={styles.calorieUnit}>{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 营养圆环图 */}
|
||||
<View style={styles.nutritionRings}>
|
||||
<NutritionRing
|
||||
label="蛋白质"
|
||||
label={t('foodAnalysisResult.nutrients.protein')}
|
||||
value={totalProtein.toFixed(1)}
|
||||
unit="克"
|
||||
unit={t('foodAnalysisResult.nutrients.unit')}
|
||||
percentage={Math.min(100, proteinPercentage)}
|
||||
color="#4CAF50"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
<NutritionRing
|
||||
label="脂肪"
|
||||
label={t('foodAnalysisResult.nutrients.fat')}
|
||||
value={totalFat.toFixed(1)}
|
||||
unit="克"
|
||||
unit={t('foodAnalysisResult.nutrients.unit')}
|
||||
percentage={Math.min(100, fatPercentage)}
|
||||
color="#FF9800"
|
||||
resetToken={animationTrigger}
|
||||
/>
|
||||
<NutritionRing
|
||||
label="碳水"
|
||||
label={t('foodAnalysisResult.nutrients.carbs')}
|
||||
value={totalCarbohydrate.toFixed(1)}
|
||||
unit="克"
|
||||
unit={t('foodAnalysisResult.nutrients.unit')}
|
||||
percentage={Math.min(100, carbohydratePercentage)}
|
||||
color="#2196F3"
|
||||
resetToken={animationTrigger}
|
||||
@@ -372,7 +375,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
{/* 食物摄入部分 */}
|
||||
<View style={styles.foodIntakeSection}>
|
||||
<Text style={styles.foodIntakeTitle}>
|
||||
{recognitionResult ? '识别结果' : '食物摄入'}
|
||||
{recognitionResult ? t('foodAnalysisResult.sections.recognitionResult') : t('foodAnalysisResult.sections.foodIntake')}
|
||||
</Text>
|
||||
{recognitionResult && recognitionResult.analysisText && (
|
||||
<Text style={styles.analysisText}>{recognitionResult.analysisText}</Text>
|
||||
@@ -384,15 +387,15 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.nonFoodIcon}>
|
||||
<Ionicons name="alert-circle-outline" size={48} color="#FF9800" />
|
||||
</View>
|
||||
<Text style={styles.nonFoodTitle}>未识别到食物</Text>
|
||||
<Text style={styles.nonFoodTitle}>{t('foodAnalysisResult.nonFood.title')}</Text>
|
||||
<Text style={styles.nonFoodMessage}>
|
||||
{recognitionResult.nonFoodMessage || recognitionResult.analysisText}
|
||||
</Text>
|
||||
<View style={styles.nonFoodSuggestions}>
|
||||
<Text style={styles.nonFoodSuggestionsTitle}>建议:</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>• 确保图片中包含食物</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>• 尝试更清晰的照片角度</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>• 避免过度模糊或光线不足</Text>
|
||||
<Text style={styles.nonFoodSuggestionsTitle}>{t('foodAnalysisResult.nonFood.suggestions.title')}</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item1')}</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item2')}</Text>
|
||||
<Text style={styles.nonFoodSuggestionItem}>{t('foodAnalysisResult.nonFood.suggestions.item3')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -411,7 +414,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
</View>
|
||||
|
||||
<View style={styles.foodIntakeCalories}>
|
||||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}千卡</Text>
|
||||
<Text style={styles.foodIntakeCaloriesValue}>{item.calories}{t('foodAnalysisResult.nutrients.caloriesUnit')}</Text>
|
||||
{shouldHideRecordBar ? null : <TouchableOpacity
|
||||
style={styles.editButton}
|
||||
onPress={() => handleEditFood(item)}
|
||||
@@ -442,7 +445,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} style={{ marginRight: 8 }} />
|
||||
<Text style={styles.retakePhotoButtonText}>重新拍照</Text>
|
||||
<Text style={styles.retakePhotoButtonText}>{t('foodAnalysisResult.actions.retake')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
@@ -471,7 +474,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
{isRecording ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.recordButtonText}>记录</Text>
|
||||
<Text style={styles.recordButtonText}>{t('foodAnalysisResult.actions.record')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -492,7 +495,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
/>
|
||||
<View style={styles.mealSelectorModal}>
|
||||
<View style={styles.mealSelectorHeader}>
|
||||
<Text style={styles.mealSelectorTitle}>选择餐次</Text>
|
||||
<Text style={styles.mealSelectorTitle}>{t('foodAnalysisResult.mealSelector.title')}</Text>
|
||||
<TouchableOpacity onPress={() => setShowMealSelector(false)}>
|
||||
<Ionicons name="close" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
@@ -539,8 +542,8 @@ export default function FoodAnalysisResultScreen() {
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{recognitionResult ?
|
||||
`置信度: ${recognitionResult.confidence}%` :
|
||||
dayjs().format('YYYY年M月D日 HH:mm')
|
||||
t('foodAnalysisResult.confidence', { value: recognitionResult.confidence }) :
|
||||
dayjs().format(t('foodAnalysisResult.dateFormats.full'))
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -551,7 +554,7 @@ export default function FoodAnalysisResultScreen() {
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('foodAnalysisResult.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -587,6 +590,8 @@ function FoodEditModal({
|
||||
onFormDataChange({ ...formData, [field]: value });
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -598,14 +603,14 @@ function FoodEditModal({
|
||||
<View style={styles.editModalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
|
||||
<Text style={styles.modalTitle}>编辑食物信息</Text>
|
||||
<Text style={styles.modalTitle}>{t('foodAnalysisResult.editModal.title')}</Text>
|
||||
|
||||
{/* 食物名称 */}
|
||||
<View style={styles.editFieldContainer}>
|
||||
<Text style={styles.editFieldLabel}>食物名称</Text>
|
||||
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.name')}</Text>
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
placeholder="输入食物名称"
|
||||
placeholder={t('foodAnalysisResult.editModal.fields.namePlaceholder')}
|
||||
placeholderTextColor="#999"
|
||||
value={formData.name}
|
||||
onChangeText={(value) => handleFieldChange('name', value)}
|
||||
@@ -615,10 +620,10 @@ function FoodEditModal({
|
||||
|
||||
{/* 重量/数量 */}
|
||||
<View style={styles.editFieldContainer}>
|
||||
<Text style={styles.editFieldLabel}>重量 (克)</Text>
|
||||
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.amount')}</Text>
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
placeholder="输入重量"
|
||||
placeholder={t('foodAnalysisResult.editModal.fields.amountPlaceholder')}
|
||||
placeholderTextColor="#999"
|
||||
value={formData.amount}
|
||||
onChangeText={(value) => handleFieldChange('amount', value)}
|
||||
@@ -628,10 +633,10 @@ function FoodEditModal({
|
||||
|
||||
{/* 卡路里 */}
|
||||
<View style={styles.editFieldContainer}>
|
||||
<Text style={styles.editFieldLabel}>卡路里 (千卡)</Text>
|
||||
<Text style={styles.editFieldLabel}>{t('foodAnalysisResult.editModal.fields.calories')}</Text>
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
placeholder="输入卡路里"
|
||||
placeholder={t('foodAnalysisResult.editModal.fields.caloriesPlaceholder')}
|
||||
placeholderTextColor="#999"
|
||||
value={formData.calories}
|
||||
onChangeText={(value) => handleFieldChange('calories', value)}
|
||||
@@ -645,13 +650,13 @@ function FoodEditModal({
|
||||
onPress={onClose}
|
||||
style={styles.modalCancelBtn}
|
||||
>
|
||||
<Text style={styles.modalCancelText}>取消</Text>
|
||||
<Text style={styles.modalCancelText}>{t('foodAnalysisResult.editModal.actions.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={onSave}
|
||||
style={[styles.modalSaveBtn, { backgroundColor: Colors.light.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>保存</Text>
|
||||
<Text style={[styles.modalSaveText, { color: Colors.light.onPrimary }]}>{t('foodAnalysisResult.editModal.actions.save')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
deleteNutritionAnalysisRecord,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function NutritionAnalysisHistoryScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -95,15 +97,15 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
setHasMore(page < response.data.totalPages);
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
const errorMessage = response.message || '获取历史记录失败';
|
||||
const errorMessage = response.message || t('nutritionAnalysisHistory.errors.fetchFailed');
|
||||
setError(errorMessage);
|
||||
Alert.alert('错误', errorMessage);
|
||||
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 获取历史记录失败:', error);
|
||||
const errorMessage = '获取历史记录失败,请稍后重试';
|
||||
const errorMessage = t('nutritionAnalysisHistory.errors.fetchFailedRetry');
|
||||
setError(errorMessage);
|
||||
Alert.alert('错误', errorMessage);
|
||||
Alert.alert(t('nutritionAnalysisHistory.errors.error'), errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
@@ -173,13 +175,13 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '成功';
|
||||
return t('nutritionAnalysisHistory.status.success');
|
||||
case 'failed':
|
||||
return '失败';
|
||||
return t('nutritionAnalysisHistory.status.failed');
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
return t('nutritionAnalysisHistory.status.processing');
|
||||
default:
|
||||
return '未知';
|
||||
return t('nutritionAnalysisHistory.status.unknown');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,15 +210,15 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
// 处理删除记录
|
||||
const handleDeleteRecord = useCallback((recordId: number) => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条营养分析记录吗?此操作无法撤销。',
|
||||
t('nutritionAnalysisHistory.delete.confirmTitle'),
|
||||
t('nutritionAnalysisHistory.delete.confirmMessage'),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('nutritionAnalysisHistory.delete.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
text: t('nutritionAnalysisHistory.delete.delete'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
@@ -231,10 +233,10 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
triggerLightHaptic();
|
||||
|
||||
// 显示成功提示
|
||||
Alert.alert('成功', '记录已删除');
|
||||
Alert.alert(t('nutritionAnalysisHistory.delete.successTitle'), t('nutritionAnalysisHistory.delete.successMessage'));
|
||||
} catch (error) {
|
||||
console.error('[HISTORY] 删除记录失败:', error);
|
||||
Alert.alert('错误', '删除失败,请稍后重试');
|
||||
Alert.alert(t('nutritionAnalysisHistory.errors.error'), t('nutritionAnalysisHistory.errors.deleteFailed'));
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
@@ -256,11 +258,11 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
<View style={styles.recordInfo}>
|
||||
{isSuccess && (
|
||||
<Text style={styles.recordTitle}>
|
||||
识别 {item.nutritionCount} 项营养素
|
||||
{t('nutritionAnalysisHistory.recognized', { count: item.nutritionCount })}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.recordDate}>
|
||||
{dayjs(item.createdAt).format('YYYY年M月D日 HH:mm')}
|
||||
{dayjs(item.createdAt).format(t('nutritionAnalysisHistory.dateFormat'))}
|
||||
</Text>
|
||||
<View style={[styles.statusBadge, { backgroundColor: getStatusColor(item.status) }]}>
|
||||
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
|
||||
@@ -327,25 +329,25 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
<>
|
||||
{mainNutrients.energy && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>热量</Text>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.energy')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.energy}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.protein && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>蛋白质</Text>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.protein')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.protein}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.carbs && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>碳水</Text>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.carbs')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.carbs}</Text>
|
||||
</View>
|
||||
)}
|
||||
{mainNutrients.fat && (
|
||||
<View style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionLabel}>脂肪</Text>
|
||||
<Text style={styles.nutritionLabel}>{t('nutritionAnalysisHistory.nutrients.fat')}</Text>
|
||||
<Text style={styles.nutritionValue}>{mainNutrients.fat}</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -371,7 +373,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.expandButtonText}>
|
||||
{isExpanded ? '收起详情' : '展开详情'}
|
||||
{isExpanded ? t('nutritionAnalysisHistory.actions.collapse') : t('nutritionAnalysisHistory.actions.expand')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={isExpanded ? 'chevron-up-outline' : 'chevron-down-outline'}
|
||||
@@ -383,7 +385,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
{/* 详细信息 */}
|
||||
{isExpanded && isSuccess && item.analysisResult && item.analysisResult.data && (
|
||||
<View style={styles.detailsContainer}>
|
||||
<Text style={styles.detailsTitle}>详细营养成分</Text>
|
||||
<Text style={styles.detailsTitle}>{t('nutritionAnalysisHistory.details.title')}</Text>
|
||||
{item.analysisResult.data.map((nutritionItem: NutritionItem) => (
|
||||
<View key={nutritionItem.key} style={styles.detailItem}>
|
||||
<View style={styles.nutritionInfo}>
|
||||
@@ -397,8 +399,8 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
))}
|
||||
|
||||
<View style={styles.metaInfo}>
|
||||
<Text style={styles.metaText}>AI 模型: {item.aiModel}</Text>
|
||||
<Text style={styles.metaText}>服务提供商: {item.aiProvider}</Text>
|
||||
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.aiModel')}: {item.aiModel}</Text>
|
||||
<Text style={styles.metaText}>{t('nutritionAnalysisHistory.details.provider')}: {item.aiProvider}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -410,8 +412,8 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="document-text-outline" size={64} color="#CCC" />
|
||||
<Text style={styles.emptyStateText}>暂无历史记录</Text>
|
||||
<Text style={styles.emptyStateSubtext}>开始识别营养成分表吧</Text>
|
||||
<Text style={styles.emptyStateText}>{t('nutritionAnalysisHistory.empty.title')}</Text>
|
||||
<Text style={styles.emptyStateSubtext}>{t('nutritionAnalysisHistory.empty.subtitle')}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -419,8 +421,8 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
const renderErrorState = () => (
|
||||
<View style={styles.errorState}>
|
||||
<Ionicons name="alert-circle-outline" size={64} color="#F44336" />
|
||||
<Text style={styles.errorStateText}>加载失败</Text>
|
||||
<Text style={styles.errorStateSubtext}>{error || '未知错误'}</Text>
|
||||
<Text style={styles.errorStateText}>{t('nutritionAnalysisHistory.errors.loadFailed')}</Text>
|
||||
<Text style={styles.errorStateSubtext}>{error || t('nutritionAnalysisHistory.errors.unknownError')}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
@@ -428,7 +430,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
fetchRecords(1, true);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>重试</Text>
|
||||
<Text style={styles.retryButtonText}>{t('nutritionAnalysisHistory.actions.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -440,7 +442,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
return (
|
||||
<View style={styles.loadingFooter}>
|
||||
<ActivityIndicator size="small" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingFooterText}>加载更多...</Text>
|
||||
<Text style={styles.loadingFooterText}>{t('nutritionAnalysisHistory.loadingMore')}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -456,7 +458,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="历史记录"
|
||||
title={t('nutritionAnalysisHistory.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
/>
|
||||
@@ -477,7 +479,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.filterButtonText, !statusFilter && styles.filterButtonTextActive]}>
|
||||
全部
|
||||
{t('nutritionAnalysisHistory.filter.all')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -494,7 +496,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.filterButtonText, statusFilter === 'success' && styles.filterButtonTextActive]}>
|
||||
成功
|
||||
{t('nutritionAnalysisHistory.status.success')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
@@ -511,7 +513,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.filterButtonText, statusFilter === 'failed' && styles.filterButtonTextActive]}>
|
||||
失败
|
||||
{t('nutritionAnalysisHistory.status.failed')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -520,7 +522,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>加载历史记录...</Text>
|
||||
<Text style={styles.loadingText}>{t('nutritionAnalysisHistory.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
@@ -555,7 +557,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{dayjs().format('YYYY年M月D日 HH:mm')}
|
||||
{dayjs().format(t('nutritionAnalysisHistory.dateFormat'))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -565,7 +567,7 @@ export default function NutritionAnalysisHistoryScreen() {
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import {
|
||||
analyzeNutritionImage,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
|
||||
export default function NutritionLabelAnalysisScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const router = useRouter();
|
||||
const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard();
|
||||
@@ -77,7 +79,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
const requestCameraPermission = async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限不足', '需要相机权限才能拍摄成分表');
|
||||
Alert.alert(t('nutritionLabelAnalysis.camera.permissionDenied'), t('nutritionLabelAnalysis.camera.permissionMessage'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -153,7 +155,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
// 直接使用服务端返回的数据,不做任何转换
|
||||
setNewAnalysisResult(analysisResponse);
|
||||
} else {
|
||||
throw new Error(analysisResponse.message || '分析失败');
|
||||
throw new Error(analysisResponse.message || t('nutritionLabelAnalysis.errors.analysisFailed.defaultMessage'));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[NUTRITION_ANALYSIS] 新API分析失败:', error);
|
||||
@@ -162,8 +164,8 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
|
||||
// 显示错误提示
|
||||
Alert.alert(
|
||||
'分析失败',
|
||||
error.message || '无法识别成分表,请尝试拍摄更清晰的照片'
|
||||
t('nutritionLabelAnalysis.errors.analysisFailed.title'),
|
||||
error.message || t('nutritionLabelAnalysis.errors.analysisFailed.message')
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
@@ -182,7 +184,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="成分表分析"
|
||||
title={t('nutritionLabelAnalysis.title')}
|
||||
onBack={() => router.back()}
|
||||
transparent={true}
|
||||
right={
|
||||
@@ -253,7 +255,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="search-outline" size={20} color="#FFF" />
|
||||
<Text style={styles.analyzeButtonText}>开始分析</Text>
|
||||
<Text style={styles.analyzeButtonText}>{t('nutritionLabelAnalysis.actions.startAnalysis')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -274,7 +276,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
<View style={styles.placeholderContainer}>
|
||||
<View style={styles.placeholderContent}>
|
||||
<Ionicons name="document-text-outline" size={48} color="#666" />
|
||||
<Text style={styles.placeholderText}>拍摄或选择成分表照片</Text>
|
||||
<Text style={styles.placeholderText}>{t('nutritionLabelAnalysis.placeholder.text')}</Text>
|
||||
</View>
|
||||
{/* 操作按钮区域 */}
|
||||
<View style={styles.imageActionButtonsContainer}>
|
||||
@@ -284,7 +286,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={Colors.light.onPrimary} />
|
||||
<Text style={styles.imageActionButtonText}>拍摄</Text>
|
||||
<Text style={styles.imageActionButtonText}>{t('nutritionLabelAnalysis.actions.takePhoto')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.imageActionButton, styles.imageActionButtonSecondary]}
|
||||
@@ -292,7 +294,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="image-outline" size={20} color={Colors.light.primary} />
|
||||
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}>相册</Text>
|
||||
<Text style={[styles.imageActionButtonText, { color: Colors.light.primary }]}>{t('nutritionLabelAnalysis.actions.selectFromAlbum')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -307,7 +309,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
<View style={styles.analysisSectionHeaderIcon}>
|
||||
<Ionicons name="document-text-outline" size={18} color="#6B6ED6" />
|
||||
</View>
|
||||
<Text style={styles.analysisSectionTitle}>营养成分详细分析</Text>
|
||||
<Text style={styles.analysisSectionTitle}>{t('nutritionLabelAnalysis.results.title')}</Text>
|
||||
</View>
|
||||
<View style={styles.analysisCardsWrapper}>
|
||||
{newAnalysisResult.data.map((item, index) => (
|
||||
@@ -352,7 +354,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
正在上传图片... {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
|
||||
{t('nutritionLabelAnalysis.status.uploading')} {uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -361,7 +363,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
{isAnalyzing && !newAnalysisResult && !isUploading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={Colors.light.primary} />
|
||||
<Text style={styles.loadingText}>正在分析成分表...</Text>
|
||||
<Text style={styles.loadingText}>{t('nutritionLabelAnalysis.status.analyzing')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
@@ -377,7 +379,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
HeaderComponent={() => (
|
||||
<View style={styles.imageViewerHeader}>
|
||||
<Text style={styles.imageViewerHeaderText}>
|
||||
{dayjs().format('YYYY年M月D日 HH:mm')}
|
||||
{dayjs().format(t('nutritionLabelAnalysis.imageViewer.dateFormat'))}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -387,7 +389,7 @@ export default function NutritionLabelAnalysisScreen() {
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>关闭</Text>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('nutritionLabelAnalysis.actions.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -514,7 +516,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
imageActionButtonText: {
|
||||
color: Colors.light.onPrimary,
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
|
||||
687
app/gallery/index.tsx
Normal file
687
app/gallery/index.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import { AiReportRecord, generateAiReport, getAiReportHistory } from '@/services/aiReport';
|
||||
import { getAuthToken } from '@/services/api';
|
||||
import { Toast } from '@/utils/toast.utils';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
Platform,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Share,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function GalleryScreen() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const { checkServiceAccess } = useVipService();
|
||||
const { openMembershipModal } = useMembershipModal();
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
|
||||
// 报告历史列表
|
||||
const [reports, setReports] = useState<AiReportRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
|
||||
const [reportImageUrl, setReportImageUrl] = useState<string | null>(null);
|
||||
const [reportLocalUri, setReportLocalUri] = useState<string | null>(null);
|
||||
const [reportModalVisible, setReportModalVisible] = useState(false);
|
||||
const [isSavingReport, setIsSavingReport] = useState(false);
|
||||
const [isSharingReport, setIsSharingReport] = useState(false);
|
||||
const reportSpinAnim = useRef(new Animated.Value(0)).current;
|
||||
const reportIconSpin = reportSpinAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg']
|
||||
});
|
||||
|
||||
const emptyImageHeight = useMemo(() => screenHeight / 1.5, [screenHeight]);
|
||||
|
||||
const todayString = useMemo(() => dayjs().format('YYYY-MM-DD'), []);
|
||||
|
||||
const reportImageSize = useMemo(() => {
|
||||
const maxWidth = Math.min(screenWidth - 40, 440);
|
||||
const maxHeight = screenHeight - 240;
|
||||
let width = maxWidth;
|
||||
let height = (maxWidth * 16) / 9;
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = (maxHeight * 9) / 16;
|
||||
}
|
||||
return { width, height };
|
||||
}, [screenHeight, screenWidth]);
|
||||
|
||||
// 加载报告历史
|
||||
const loadReports = useCallback(async (pageNum: number, refresh = false) => {
|
||||
try {
|
||||
const response = await getAiReportHistory({
|
||||
page: pageNum,
|
||||
pageSize: 10,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
if (refresh) {
|
||||
setReports(response.records);
|
||||
} else {
|
||||
setReports(prev => [...prev, ...response.records]);
|
||||
}
|
||||
setHasMore(pageNum < response.totalPages);
|
||||
setPage(pageNum);
|
||||
} catch (error: any) {
|
||||
console.error('load-ai-report-history-failed', error);
|
||||
if (refresh) {
|
||||
Toast.error(t('statistics.aiReport.loadFailed', '加载报告历史失败'));
|
||||
}
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setIsLoading(true);
|
||||
await loadReports(1, true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
init();
|
||||
}, [loadReports]);
|
||||
|
||||
// 下拉刷新
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
await loadReports(1, true);
|
||||
setIsRefreshing(false);
|
||||
}, [loadReports]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
setIsLoadingMore(true);
|
||||
await loadReports(page + 1, false);
|
||||
setIsLoadingMore(false);
|
||||
}, [isLoadingMore, hasMore, page, loadReports]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGeneratingReport) {
|
||||
reportSpinAnim.stopAnimation();
|
||||
return;
|
||||
}
|
||||
reportSpinAnim.setValue(0);
|
||||
const loop = Animated.loop(
|
||||
Animated.timing(reportSpinAnim, {
|
||||
toValue: 1,
|
||||
duration: 1400,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [isGeneratingReport, reportSpinAnim]);
|
||||
|
||||
const handleGenerateReport = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok || isGeneratingReport) return;
|
||||
|
||||
// 检查 VIP 权限
|
||||
const access = checkServiceAccess();
|
||||
if (!access.canUseService) {
|
||||
openMembershipModal({
|
||||
onPurchaseSuccess: () => {
|
||||
// 购买成功后自动触发生成
|
||||
handleGenerateReport();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingReport(true);
|
||||
setReportLocalUri(null);
|
||||
Toast.info(t('statistics.aiReport.generating', '正在生成健康报告,预计 10~30 秒…'));
|
||||
try {
|
||||
const response = await generateAiReport({ date: todayString });
|
||||
const imageUrl = (response as any)?.imageUrl ?? (response as any)?.url ?? (response as any)?.image_url;
|
||||
if (!imageUrl) {
|
||||
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
|
||||
}
|
||||
setReportImageUrl(imageUrl);
|
||||
setReportModalVisible(true);
|
||||
Toast.success(t('statistics.aiReport.success', '报告已生成'));
|
||||
// 生成成功后刷新列表
|
||||
handleRefresh();
|
||||
} catch (error: any) {
|
||||
console.error('generate-ai-report-failed', error);
|
||||
Toast.error(error?.message ?? t('statistics.aiReport.failed', '生成报告失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsGeneratingReport(false);
|
||||
}
|
||||
}, [ensureLoggedIn, isGeneratingReport, checkServiceAccess, openMembershipModal, t, todayString, handleRefresh]);
|
||||
|
||||
const prepareLocalReportImage = useCallback(async () => {
|
||||
if (!reportImageUrl) {
|
||||
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
|
||||
}
|
||||
if (reportLocalUri) {
|
||||
return reportLocalUri;
|
||||
}
|
||||
const fileUri = `${FileSystem.cacheDirectory}ai-report-${Date.now()}.jpg`;
|
||||
const token = await getAuthToken();
|
||||
const download = await FileSystem.downloadAsync(
|
||||
reportImageUrl,
|
||||
fileUri,
|
||||
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
|
||||
);
|
||||
if (!download?.uri) {
|
||||
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
|
||||
}
|
||||
setReportLocalUri(download.uri);
|
||||
return download.uri;
|
||||
}, [reportImageUrl, reportLocalUri, t]);
|
||||
|
||||
const handleSaveReport = useCallback(async () => {
|
||||
if (isSavingReport) return;
|
||||
try {
|
||||
setIsSavingReport(true);
|
||||
const permission = await MediaLibrary.requestPermissionsAsync();
|
||||
if (permission.status !== 'granted') {
|
||||
Toast.warning(t('statistics.aiReport.permission', '需要相册权限才能保存图片'));
|
||||
return;
|
||||
}
|
||||
const localUri = await prepareLocalReportImage();
|
||||
await MediaLibrary.saveToLibraryAsync(localUri);
|
||||
Toast.success(t('statistics.aiReport.saved', '已保存到相册'));
|
||||
} catch (error: any) {
|
||||
console.error('save-ai-report-failed', error);
|
||||
Toast.error(error?.message ?? t('statistics.aiReport.saveFailed', '保存失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsSavingReport(false);
|
||||
}
|
||||
}, [isSavingReport, prepareLocalReportImage, t]);
|
||||
|
||||
const handleShareReport = useCallback(async () => {
|
||||
if (isSharingReport) return;
|
||||
try {
|
||||
setIsSharingReport(true);
|
||||
const localUri = await prepareLocalReportImage();
|
||||
await Share.share({
|
||||
message: t('statistics.aiReport.shareMessage', '这是我的 AI 健康报告,分享给你看看!'),
|
||||
url: Platform.OS === 'ios' ? localUri : `file://${localUri}`,
|
||||
title: t('statistics.aiReport.shareTitle', 'AI 健康报告')
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('share-ai-report-failed', error);
|
||||
Toast.error(error?.message ?? t('statistics.aiReport.shareFailed', '分享失败,请稍后重试'));
|
||||
} finally {
|
||||
setIsSharingReport(false);
|
||||
}
|
||||
}, [isSharingReport, prepareLocalReportImage, t]);
|
||||
|
||||
// 点击卡片查看报告
|
||||
const handleCardPress = useCallback((report: AiReportRecord) => {
|
||||
if (!report.imageUrl) return;
|
||||
setReportImageUrl(report.imageUrl);
|
||||
setReportLocalUri(null);
|
||||
setReportModalVisible(true);
|
||||
}, []);
|
||||
|
||||
// 滚动到底部加载更多
|
||||
const handleScroll = useCallback((event: any) => {
|
||||
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
|
||||
const paddingToBottom = 100;
|
||||
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}, [handleLoadMore]);
|
||||
|
||||
const headerRight = isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={handleGenerateReport}
|
||||
disabled={isGeneratingReport}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.reportButton}
|
||||
glassEffectStyle="clear"
|
||||
isInteractive
|
||||
>
|
||||
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</Animated.View>
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleGenerateReport}
|
||||
style={[styles.reportButton, styles.reportButtonFallback]}
|
||||
disabled={isGeneratingReport}
|
||||
>
|
||||
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const headerTitle = (
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.aiReport.galleryTitle', 'AI 报告画廊')}</Text>
|
||||
<Text style={styles.headerSubtitle}>{t('statistics.aiReport.gallerySubtitle', '沉浸式浏览你的健康报告')}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
<LinearGradient
|
||||
colors={['#f0f4ff', '#fdf8ff', '#f6f8fa']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<HeaderBar
|
||||
title={headerTitle}
|
||||
right={headerRight}
|
||||
tone="light"
|
||||
transparent
|
||||
/>
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 56,
|
||||
paddingBottom: 40,
|
||||
paddingHorizontal: 16,
|
||||
...(reports.length === 0 && !isLoading ? { flexGrow: 1, justifyContent: 'center' } : {})
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#6B7280"
|
||||
/>
|
||||
}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#3B82F6" />
|
||||
</View>
|
||||
) : reports.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Pressable
|
||||
style={styles.emptyImageCard}
|
||||
onPress={() => {
|
||||
const imageUrl = i18n.language?.startsWith('en')
|
||||
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
|
||||
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg';
|
||||
setReportImageUrl(imageUrl);
|
||||
setReportLocalUri(null);
|
||||
setReportModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{
|
||||
uri: i18n.language?.startsWith('en')
|
||||
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
|
||||
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg'
|
||||
}}
|
||||
style={[styles.emptyImage, { height: emptyImageHeight }]}
|
||||
contentFit="contain"
|
||||
transition={300}
|
||||
/>
|
||||
<View style={styles.emptyImageOverlay}>
|
||||
<View style={styles.previewHint}>
|
||||
<Ionicons name="expand-outline" size={14} color="#fff" />
|
||||
<Text style={styles.previewHintText}>{t('statistics.aiReport.clickToPreview', '点击预览模板')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={styles.emptyTitle}>{t('statistics.aiReport.emptyHistory', '暂无报告记录')}</Text>
|
||||
<Text style={styles.emptySubtitle}>{t('statistics.aiReport.emptyHistoryHint', '点击右上方按钮生成你的第一份报告')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.galleryGrid}>
|
||||
{reports.map((report) => (
|
||||
<Pressable
|
||||
key={report.id}
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={() => handleCardPress(report)}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: report.imageUrl }}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
transition={250}
|
||||
/>
|
||||
<View style={styles.cardBody}>
|
||||
<Text numberOfLines={1} style={styles.cardTitle}>
|
||||
{dayjs(report.reportDate).format('YYYY年M月D日')}
|
||||
</Text>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{dayjs(report.createdAt).format('HH:mm')} {t('statistics.aiReport.generated', '生成')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<View style={styles.loadingMoreContainer}>
|
||||
<ActivityIndicator size="small" color="#6B7280" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{reportModalVisible && (
|
||||
<View style={styles.modalOverlay}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => setReportModalVisible(false)} />
|
||||
<View style={styles.modalCard}>
|
||||
{reportImageUrl ? (
|
||||
<ExpoImage
|
||||
source={{ uri: reportImageUrl }}
|
||||
style={[styles.reportImage, { width: reportImageSize.width, height: reportImageSize.height }]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.reportImageFallback, { width: reportImageSize.width, height: reportImageSize.height }]}>
|
||||
<Text style={styles.reportFallbackText}>{t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, isSavingReport && styles.modalButtonDisabled]}
|
||||
onPress={handleSaveReport}
|
||||
disabled={isSavingReport}
|
||||
>
|
||||
<Ionicons name="download-outline" size={18} color="#0F172A" />
|
||||
<Text style={styles.modalButtonText}>
|
||||
{isSavingReport ? t('statistics.aiReport.saving', '保存中…') : t('statistics.aiReport.save', '保存')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, isSharingReport && styles.modalButtonDisabled]}
|
||||
onPress={handleShareReport}
|
||||
disabled={isSharingReport}
|
||||
>
|
||||
<Ionicons name="share-social-outline" size={18} color="#0F172A" />
|
||||
<Text style={styles.modalButtonText}>
|
||||
{isSharingReport ? t('statistics.aiReport.sharing', '分享中…') : t('statistics.aiReport.share', '分享')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Pressable style={styles.closeRow} onPress={() => setReportModalVisible(false)}>
|
||||
<Ionicons name="close" size={18} color="#4B5563" />
|
||||
<Text style={styles.closeLabel}>{t('statistics.aiReport.close', '收起')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f7f8fb',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#0F172A',
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSubtitle: {
|
||||
marginTop: 2,
|
||||
color: '#6B7280',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
textAlign: 'center',
|
||||
},
|
||||
reportButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
reportButtonFallback: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
reportIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#E0F2FE',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
paddingTop: 100,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
},
|
||||
emptyImageCard: {
|
||||
width: '100%',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 6,
|
||||
},
|
||||
emptyImage: {
|
||||
width: '100%',
|
||||
height: 380,
|
||||
},
|
||||
emptyImageOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
borderRadius: 20,
|
||||
},
|
||||
previewHint: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
},
|
||||
previewHintText: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
color: '#fff',
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#1F2937',
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
color: '#6B7280',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: '#3B82F6',
|
||||
borderRadius: 28,
|
||||
marginTop: 8,
|
||||
shadowColor: '#3B82F6',
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 4,
|
||||
},
|
||||
emptyButtonText: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#fff',
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
galleryGrid: {
|
||||
gap: 18,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 22,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
elevation: 6,
|
||||
},
|
||||
cardPressed: {
|
||||
transform: [{ scale: 0.99 }],
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: 360,
|
||||
},
|
||||
cardBody: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
gap: 4,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#111827',
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
modalOverlay: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(12, 18, 27, 0.78)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: '#FDFDFE',
|
||||
borderRadius: 20,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 18,
|
||||
elevation: 16,
|
||||
},
|
||||
reportImage: {
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
reportImageFallback: {
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
reportFallbackText: {
|
||||
textAlign: 'center',
|
||||
color: '#111827',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
modalButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#E0F2FE',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#BAE6FD',
|
||||
},
|
||||
modalButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
modalButtonText: {
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
closeRow: {
|
||||
marginTop: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
closeLabel: {
|
||||
fontSize: 14,
|
||||
color: '#4B5563',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
620
app/health/family-invite.tsx
Normal file
620
app/health/family-invite.tsx
Normal file
@@ -0,0 +1,620 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import {
|
||||
fetchFamilyGroup,
|
||||
generateInviteCode,
|
||||
selectFamilyGroup,
|
||||
selectFamilyHealthLoading,
|
||||
selectInviteCode,
|
||||
selectInviteLoading,
|
||||
} from '@/store/familyHealthSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Stack } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Share,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function FamilyInviteScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [showQRModal, setShowQRModal] = useState(false);
|
||||
|
||||
// Redux state
|
||||
const familyGroup = useAppSelector(selectFamilyGroup);
|
||||
const inviteCode = useAppSelector(selectInviteCode);
|
||||
const isLoading = useAppSelector(selectFamilyHealthLoading);
|
||||
const isInviteLoading = useAppSelector(selectInviteLoading);
|
||||
|
||||
// 初始化时获取家庭组信息
|
||||
useEffect(() => {
|
||||
dispatch(fetchFamilyGroup());
|
||||
}, [dispatch]);
|
||||
|
||||
// 处理邀请按钮点击
|
||||
const handleInvite = async () => {
|
||||
try {
|
||||
// 生成邀请码
|
||||
await dispatch(generateInviteCode(24)).unwrap();
|
||||
|
||||
// 显示二维码弹窗
|
||||
setShowQRModal(true);
|
||||
} catch (error: any) {
|
||||
Alert.alert('邀请失败', error?.message || '请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 分享邀请码
|
||||
const handleShare = async () => {
|
||||
if (!inviteCode) return;
|
||||
|
||||
try {
|
||||
await Share.share({
|
||||
message: `邀请您加入我的家庭健康管理组!\n邀请码:${inviteCode.inviteCode}\n有效期至:${new Date(inviteCode.expiresAt).toLocaleString()}`,
|
||||
title: '家庭健康管理邀请',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('分享失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<HeaderBar title="" transparent />
|
||||
|
||||
<LinearGradient
|
||||
colors={['#Eef2FF', '#F5F3FF', '#FFFFFF']}
|
||||
style={styles.background}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 40 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header Title Area */}
|
||||
<View style={styles.headerSection}>
|
||||
<Text style={styles.mainTitle}>家庭健康管理</Text>
|
||||
<Text style={styles.mainTitle}>保障全家健康</Text>
|
||||
|
||||
<View style={styles.subtitleBadge}>
|
||||
<Ionicons name="home" size={12} color="#5B4CFF" />
|
||||
<Text style={styles.subtitleText}>全家互相督促,让关爱不遗漏</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Hero Image / House Icon Area */}
|
||||
<View style={styles.heroContainer}>
|
||||
{/* Floating Labels */}
|
||||
<View style={[styles.floatingLabel, styles.labelLeft]}>
|
||||
<Text style={styles.floatingLabelText}>实时管理</Text>
|
||||
<View style={styles.dot} />
|
||||
</View>
|
||||
<View style={[styles.floatingLabel, styles.labelRight]}>
|
||||
<View style={styles.dot} />
|
||||
<Text style={styles.floatingLabelText}>守护家庭健康</Text>
|
||||
</View>
|
||||
|
||||
{/* Main 3D House Icon Placeholder */}
|
||||
<View style={styles.houseIconPlaceholder}>
|
||||
<LinearGradient
|
||||
colors={['#A78BFA', '#5B4CFF']}
|
||||
style={styles.houseIconGradient}
|
||||
>
|
||||
<Ionicons name="heart" size={60} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Features Grid */}
|
||||
<View style={styles.featuresCard}>
|
||||
<View style={styles.featureItem}>
|
||||
<View style={[styles.featureIcon, { backgroundColor: '#EEF2FF' }]}>
|
||||
<Ionicons name="share-social" size={24} color="#5B4CFF" />
|
||||
</View>
|
||||
<Text style={styles.featureTitle}>数据共享</Text>
|
||||
<Text style={styles.featureDesc}>家人档案共同维护</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.featureItem}>
|
||||
<View style={[styles.featureIcon, { backgroundColor: '#FEF2F2' }]}>
|
||||
<Ionicons name="alert-circle" size={24} color="#EF4444" />
|
||||
</View>
|
||||
<Text style={styles.featureTitle}>异常提醒</Text>
|
||||
<Text style={styles.featureDesc}>数据异常实时提醒</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.featureItem}>
|
||||
<View style={[styles.featureIcon, { backgroundColor: '#FFF7ED' }]}>
|
||||
<Ionicons name="medkit" size={24} color="#F97316" />
|
||||
</View>
|
||||
<Text style={styles.featureTitle}>用药监督</Text>
|
||||
<Text style={styles.featureDesc}>用药情况远程监督</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Steps Section */}
|
||||
<View style={styles.stepsContainer}>
|
||||
<Text style={styles.stepsTitle}>简单3步,帮家人管理档案</Text>
|
||||
<View style={styles.stepsSubtitleContainer}>
|
||||
<Text style={styles.stepsSubtitle}>最多邀请6人,分享二维码有效期24小时</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepsRow}>
|
||||
<View style={styles.stepItem}>
|
||||
<Text style={styles.stepNumber}>1</Text>
|
||||
<Text style={styles.stepDesc}>分享二维码邀请</Text>
|
||||
<View style={styles.stepPhoneMockup}>
|
||||
<View style={styles.mockupScreen} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
|
||||
|
||||
<View style={styles.stepItem}>
|
||||
<Text style={styles.stepNumber}>2</Text>
|
||||
<Text style={styles.stepDesc}>家人下载登录App</Text>
|
||||
<View style={styles.stepPhoneMockup}>
|
||||
<View style={styles.mockupScreen} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Ionicons name="chevron-forward" size={20} color="#D1D5DB" style={{ marginTop: 40 }} />
|
||||
|
||||
<View style={styles.stepItem}>
|
||||
<Text style={styles.stepNumber}>3</Text>
|
||||
<Text style={styles.stepDesc}>扫二维码加入</Text>
|
||||
<View style={styles.stepPhoneMockup}>
|
||||
<View style={styles.mockupScreen} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom Spacing */}
|
||||
<View style={{ height: 120 }} />
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Action Area */}
|
||||
<View style={[styles.bottomArea, { paddingBottom: insets.bottom + 16 }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.checkboxRow}
|
||||
onPress={() => setAgreed(!agreed)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons
|
||||
name={agreed ? "checkmark-circle" : "ellipse-outline"}
|
||||
size={20}
|
||||
color={agreed ? "#5B4CFF" : "#9CA3AF"}
|
||||
/>
|
||||
<Text style={styles.checkboxText}>
|
||||
申请对方同意我查看并管理其健康档案,有数据异常预警我
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.inviteButton, (!agreed || isLoading) && styles.inviteButtonDisabled]}
|
||||
disabled={!agreed || isLoading}
|
||||
onPress={handleInvite}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.inviteButtonText}>立即邀请</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
<Modal
|
||||
visible={showQRModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowQRModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>邀请家人加入</Text>
|
||||
<TouchableOpacity onPress={() => setShowQRModal(false)}>
|
||||
<Ionicons name="close" size={24} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{isInviteLoading ? (
|
||||
<View style={styles.qrContainer}>
|
||||
<ActivityIndicator size="large" color="#5B4CFF" />
|
||||
</View>
|
||||
) : inviteCode ? (
|
||||
<>
|
||||
<View style={styles.qrContainer}>
|
||||
{/* 邀请码大字展示(替代二维码,后续可安装 react-native-qrcode-svg 实现) */}
|
||||
<View style={styles.inviteCodeDisplay}>
|
||||
<Ionicons name="qr-code-outline" size={48} color="#5B4CFF" />
|
||||
<Text style={styles.inviteCodeBig}>{inviteCode.inviteCode}</Text>
|
||||
<Text style={styles.inviteCodeHint}>请让家人在 App 中输入此邀请码</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inviteCodeContainer}>
|
||||
<Text style={styles.inviteCodeLabel}>邀请码</Text>
|
||||
<Text style={styles.inviteCodeText}>{inviteCode.inviteCode}</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.expireText}>
|
||||
有效期至:{new Date(inviteCode.expiresAt).toLocaleString()}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={styles.shareButton} onPress={handleShare}>
|
||||
<Ionicons name="share-outline" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.shareButtonText}>分享邀请</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
background: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
headerSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 30,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
lineHeight: 36,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitleBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
marginTop: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
subtitleText: {
|
||||
fontSize: 12,
|
||||
color: '#5B4CFF',
|
||||
marginLeft: 6,
|
||||
fontWeight: '600',
|
||||
},
|
||||
heroContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 180,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
},
|
||||
houseIconPlaceholder: {
|
||||
width: 140,
|
||||
height: 140,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
houseIconGradient: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transform: [{ rotate: '45deg' }],
|
||||
shadowColor: '#5B4CFF',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
floatingLabel: {
|
||||
position: 'absolute',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
labelLeft: {
|
||||
left: 0,
|
||||
top: 40,
|
||||
},
|
||||
labelRight: {
|
||||
right: 0,
|
||||
top: 20,
|
||||
},
|
||||
floatingLabelText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontWeight: '600',
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
dot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#5B4CFF',
|
||||
},
|
||||
featuresCard: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
featureItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
featureIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
featureTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
marginBottom: 4,
|
||||
},
|
||||
featureDesc: {
|
||||
fontSize: 10,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stepsContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
paddingBottom: 30,
|
||||
marginBottom: 20,
|
||||
},
|
||||
stepsTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
stepsSubtitleContainer: {
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignSelf: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 10,
|
||||
marginBottom: 24,
|
||||
},
|
||||
stepsSubtitle: {
|
||||
fontSize: 11,
|
||||
color: '#6B7280',
|
||||
},
|
||||
stepsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
stepItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#5B4CFF',
|
||||
marginBottom: 8,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
stepDesc: {
|
||||
fontSize: 12,
|
||||
color: '#4B5563',
|
||||
textAlign: 'center',
|
||||
marginBottom: 12,
|
||||
height: 32,
|
||||
},
|
||||
stepPhoneMockup: {
|
||||
width: 60,
|
||||
height: 100,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E5E7EB',
|
||||
padding: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
mockupScreen: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 6,
|
||||
},
|
||||
bottomArea: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: 16,
|
||||
paddingHorizontal: 20,
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
},
|
||||
checkboxRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
checkboxText: {
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
lineHeight: 18,
|
||||
},
|
||||
inviteButton: {
|
||||
backgroundColor: '#5B4CFF',
|
||||
borderRadius: 28,
|
||||
height: 56,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#5B4CFF',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
inviteButtonDisabled: {
|
||||
backgroundColor: '#C4B5FD',
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
inviteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
},
|
||||
qrContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 16,
|
||||
marginBottom: 20,
|
||||
minHeight: 180,
|
||||
},
|
||||
inviteCodeDisplay: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
inviteCodeBig: {
|
||||
fontSize: 36,
|
||||
fontWeight: 'bold',
|
||||
color: '#5B4CFF',
|
||||
letterSpacing: 4,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
inviteCodeHint: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
inviteCodeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
inviteCodeLabel: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
marginRight: 8,
|
||||
},
|
||||
inviteCodeText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#5B4CFF',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
expireText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
shareButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#5B4CFF',
|
||||
borderRadius: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
shareButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
617
app/health/profile.tsx
Normal file
617
app/health/profile.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
import { HealthProgressRing } from '@/components/health/HealthProgressRing';
|
||||
import { BasicInfoTab } from '@/components/health/tabs/BasicInfoTab';
|
||||
import { CheckupRecordsTab } from '@/components/health/tabs/CheckupRecordsTab';
|
||||
import { HealthHistoryTab } from '@/components/health/tabs/HealthHistoryTab';
|
||||
import { MedicalRecordsTab } from '@/components/health/tabs/MedicalRecordsTab';
|
||||
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
fetchFamilyGroup,
|
||||
joinFamilyGroup,
|
||||
selectFamilyGroup,
|
||||
} from '@/store/familyHealthSlice';
|
||||
import {
|
||||
fetchHealthHistory,
|
||||
selectHealthHistoryProgress
|
||||
} from '@/store/healthSlice';
|
||||
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
|
||||
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 { Stack, useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function HealthProfileScreen() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const { t } = useI18n();
|
||||
const dispatch = useAppDispatch();
|
||||
const { ensureLoggedIn } = useAuthGuard();
|
||||
const glassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [joinModalVisible, setJoinModalVisible] = useState(false);
|
||||
const [inviteCodeInput, setInviteCodeInput] = useState('');
|
||||
const [selectedRelationship, setSelectedRelationship] = useState('');
|
||||
const [isJoining, setIsJoining] = useState(false);
|
||||
const [joinError, setJoinError] = useState<string | null>(null);
|
||||
|
||||
// Redux state
|
||||
const familyGroup = useAppSelector(selectFamilyGroup);
|
||||
const medicalRecords = useAppSelector((state) => state.health.medicalRecords);
|
||||
const records = medicalRecords?.records || [];
|
||||
const prescriptions = medicalRecords?.prescriptions || [];
|
||||
|
||||
// Calculate Medical Records Count
|
||||
const medicalRecordsCount = useMemo(() => records.length + prescriptions.length, [records, prescriptions]);
|
||||
|
||||
// 亲属关系选项
|
||||
const relationshipOptions = useMemo(() => [
|
||||
{ key: 'spouse', label: t('familyGroup.relationships.spouse') },
|
||||
{ key: 'father', label: t('familyGroup.relationships.father') },
|
||||
{ key: 'mother', label: t('familyGroup.relationships.mother') },
|
||||
{ key: 'son', label: t('familyGroup.relationships.son') },
|
||||
{ key: 'daughter', label: t('familyGroup.relationships.daughter') },
|
||||
{ key: 'grandfather', label: t('familyGroup.relationships.grandfather') },
|
||||
{ key: 'grandmother', label: t('familyGroup.relationships.grandmother') },
|
||||
{ key: 'grandson', label: t('familyGroup.relationships.grandson') },
|
||||
{ key: 'granddaughter', label: t('familyGroup.relationships.granddaughter') },
|
||||
{ key: 'brother', label: t('familyGroup.relationships.brother') },
|
||||
{ key: 'sister', label: t('familyGroup.relationships.sister') },
|
||||
{ key: 'uncle', label: t('familyGroup.relationships.uncle') },
|
||||
{ key: 'aunt', label: t('familyGroup.relationships.aunt') },
|
||||
{ key: 'nephew', label: t('familyGroup.relationships.nephew') },
|
||||
{ key: 'niece', label: t('familyGroup.relationships.niece') },
|
||||
{ key: 'cousin', label: t('familyGroup.relationships.cousin') },
|
||||
{ key: 'other', label: t('familyGroup.relationships.other') },
|
||||
], [t]);
|
||||
|
||||
// Mock user data - in a real app this would come from Redux/Context
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
const displayName = userProfile.name?.trim() ? userProfile.name : DEFAULT_MEMBER_NAME;
|
||||
const avatarUrl = userProfile.avatar || 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/2.jpeg';
|
||||
|
||||
// 从 Redux 获取健康史进度
|
||||
const healthHistoryProgress = useAppSelector(selectHealthHistoryProgress);
|
||||
|
||||
// Mock health data
|
||||
const healthData = {
|
||||
bmi: userProfile.weight && userProfile.height ? (parseFloat(userProfile.weight) / Math.pow(parseFloat(userProfile.height) / 100, 2)).toFixed(1) : '--',
|
||||
height: userProfile.height ? `${parseFloat(userProfile.height).toFixed(1)}` : '--',
|
||||
weight: userProfile.weight ? `${parseFloat(userProfile.weight).toFixed(1)}` : '--',
|
||||
waist: userProfile.waistCircumference ? `${parseFloat(userProfile.waistCircumference.toString()).toFixed(1)}` : '--',
|
||||
status: '健康状况良好',
|
||||
statusDesc: '请继续保持良好的生活习惯',
|
||||
statusMessage: '您的健康状况不错哦~'
|
||||
};
|
||||
|
||||
// Calculate Basic Info completion percentage
|
||||
const basicInfoProgress = useMemo(() => {
|
||||
let filledCount = 0;
|
||||
const totalFields = 3; // height, weight, waist
|
||||
|
||||
if (userProfile.height && parseFloat(userProfile.height) > 0) filledCount++;
|
||||
if (userProfile.weight && parseFloat(userProfile.weight) > 0) filledCount++;
|
||||
if (userProfile.waistCircumference && parseFloat(userProfile.waistCircumference.toString()) > 0) filledCount++;
|
||||
|
||||
return Math.round((filledCount / totalFields) * 100);
|
||||
}, [userProfile.height, userProfile.weight, userProfile.waistCircumference]);
|
||||
|
||||
// 初始化获取家庭组信息和健康史数据
|
||||
useEffect(() => {
|
||||
dispatch(fetchFamilyGroup());
|
||||
dispatch(fetchHealthHistory());
|
||||
}, [dispatch]);
|
||||
|
||||
// 重置弹窗状态
|
||||
useEffect(() => {
|
||||
if (!joinModalVisible) {
|
||||
setInviteCodeInput('');
|
||||
setSelectedRelationship('');
|
||||
setJoinError(null);
|
||||
}
|
||||
}, [joinModalVisible]);
|
||||
|
||||
// 打开加入弹窗
|
||||
const handleOpenJoin = useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
setJoinModalVisible(true);
|
||||
}, [ensureLoggedIn]);
|
||||
|
||||
// 提交加入家庭组
|
||||
const handleSubmitJoin = useCallback(async () => {
|
||||
if (isJoining) return;
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
|
||||
const code = inviteCodeInput.trim().toUpperCase();
|
||||
|
||||
if (!code) {
|
||||
setJoinError(t('familyGroup.errors.emptyCode'));
|
||||
return;
|
||||
}
|
||||
if (!selectedRelationship) {
|
||||
setJoinError(t('familyGroup.errors.emptyRelationship'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取选中关系的显示文本
|
||||
const relationshipLabel = relationshipOptions.find(r => r.key === selectedRelationship)?.label || selectedRelationship;
|
||||
|
||||
setIsJoining(true);
|
||||
setJoinError(null);
|
||||
|
||||
try {
|
||||
await dispatch(joinFamilyGroup({ inviteCode: code, relationship: relationshipLabel })).unwrap();
|
||||
await dispatch(fetchFamilyGroup());
|
||||
setJoinModalVisible(false);
|
||||
Toast.success(t('familyGroup.success'));
|
||||
} catch (error) {
|
||||
const message = typeof error === 'string' ? error : '加入失败,请检查邀请码是否正确';
|
||||
setJoinError(message);
|
||||
} finally {
|
||||
setIsJoining(false);
|
||||
}
|
||||
}, [dispatch, ensureLoggedIn, inviteCodeInput, isJoining, selectedRelationship, relationshipOptions, t]);
|
||||
|
||||
const gradientColors: [string, string] =
|
||||
theme === 'dark'
|
||||
? ['#1f2230', '#10131e']
|
||||
: [colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd];
|
||||
|
||||
const tabs = [
|
||||
t('health.tabs.healthProfile.basicInfo'),
|
||||
t('health.tabs.healthProfile.healthHistory'),
|
||||
// t('health.tabs.healthProfile.medicalRecords'),
|
||||
t('health.tabs.healthProfile.checkupRecords'),
|
||||
t('health.tabs.healthProfile.medicineBox')
|
||||
];
|
||||
const tabIcons = ["person", "time", "folder", "clipboard", "medkit"];
|
||||
|
||||
const handleTabPress = (index: number) => {
|
||||
if (index === 3) {
|
||||
// Handle Medicine Box tab specially
|
||||
router.push('/medications/manage-medications');
|
||||
return;
|
||||
}
|
||||
setActiveTab(index);
|
||||
};
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case 0:
|
||||
return <BasicInfoTab healthData={healthData} />;
|
||||
case 1:
|
||||
return <HealthHistoryTab />;
|
||||
case 2:
|
||||
return <MedicalRecordsTab />;
|
||||
case 3:
|
||||
return <CheckupRecordsTab />;
|
||||
default:
|
||||
return <BasicInfoTab healthData={healthData} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<Stack.Screen options={{ headerShown: false }} />
|
||||
<LinearGradient colors={gradientColors} style={StyleSheet.absoluteFillObject} />
|
||||
|
||||
<HeaderBar
|
||||
title={t('health.tabs.healthProfile.title')}
|
||||
transparent
|
||||
right={
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{/* 加入家庭组按钮 - 仅在未加入家庭组时显示 */}
|
||||
{!familyGroup && (
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={handleOpenJoin} style={{ marginRight: 10 }}>
|
||||
{glassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.joinButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255,255,255,0.18)"
|
||||
isInteractive
|
||||
>
|
||||
<Text style={styles.joinButtonLabel}>加入</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.joinButtonGlass, styles.joinButtonFallback]}>
|
||||
<Text style={[styles.joinButtonLabel, { color: colorTokens.text }]}>加入</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={{ marginRight: 12 }}>
|
||||
<Ionicons name="settings-outline" size={22} color="#1F2937" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContent, { paddingTop: insets.top + 60 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Top Section with Avatar and Status */}
|
||||
<View style={styles.topSection}>
|
||||
<View style={styles.avatarRow}>
|
||||
<View style={styles.miniAvatarContainer}>
|
||||
<Image source={{ uri: avatarUrl }} style={styles.miniAvatar} />
|
||||
<Text style={styles.miniAvatarName}>{displayName}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
|
||||
>
|
||||
<Ionicons name="add" size={16} color="#6B7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons - Replaced with HealthProgressRing */}
|
||||
<View style={styles.actionButtonsRow}>
|
||||
<HealthProgressRing
|
||||
title={t('health.tabs.healthProfile.basicInfo')}
|
||||
progress={basicInfoProgress}
|
||||
gradientColors={['#9B8AFB', '#5B4CFF']}
|
||||
/>
|
||||
<HealthProgressRing
|
||||
title={t('health.tabs.healthProfile.healthHistory')}
|
||||
progress={healthHistoryProgress}
|
||||
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||
label={healthHistoryProgress.toString()}
|
||||
suffix="%"
|
||||
/>
|
||||
<HealthProgressRing
|
||||
title={t('health.tabs.healthProfile.medicalRecords')}
|
||||
progress={0}
|
||||
gradientColors={['#E0E7FF', '#C7D2FE']}
|
||||
label={medicalRecordsCount.toString()}
|
||||
suffix="份"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Family Invite Banner */}
|
||||
<TouchableOpacity
|
||||
style={styles.inviteBanner}
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push(ROUTES.HEALTH_FAMILY_INVITE)}
|
||||
>
|
||||
<View style={styles.inviteContent}>
|
||||
<View style={styles.inviteIconContainer}>
|
||||
<Ionicons name="home" size={18} color="#5B4CFF" />
|
||||
</View>
|
||||
<Text style={styles.inviteText}>{t('health.tabs.healthProfile.subtitle')}</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color="#6B7280" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Tab/Segment Control */}
|
||||
<View style={styles.segmentControl}>
|
||||
{tabs.map((tab, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.segmentItem}
|
||||
onPress={() => handleTabPress(index)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.segmentIconPlaceholder, index === activeTab && styles.segmentIconActive]}>
|
||||
<Ionicons
|
||||
name={tabIcons[index] as any}
|
||||
size={20}
|
||||
color={index === activeTab ? "#5B4CFF" : "#6B7280"}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.segmentText, index === activeTab && styles.segmentTextActive]}>{tab}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Active Tab Content */}
|
||||
{renderActiveTab()}
|
||||
|
||||
{/* Privacy Notice Footer */}
|
||||
<View style={styles.privacyNoticeContainer}>
|
||||
<View style={styles.privacyIconWrapper}>
|
||||
<Ionicons name="shield-checkmark" size={16} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.privacyNoticeText}>
|
||||
{t('health.tabs.healthProfile.privacyNotice')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
{/* 加入家庭组弹窗 */}
|
||||
<ConfirmationSheet
|
||||
visible={joinModalVisible}
|
||||
onClose={() => setJoinModalVisible(false)}
|
||||
onConfirm={handleSubmitJoin}
|
||||
title={t('familyGroup.joinTitle')}
|
||||
description={t('familyGroup.joinDescription')}
|
||||
confirmText={isJoining ? t('familyGroup.joining') : t('familyGroup.joinButton')}
|
||||
cancelText={t('familyGroup.cancel')}
|
||||
loading={isJoining}
|
||||
content={
|
||||
<View style={styles.joinModalContent}>
|
||||
{/* 邀请码输入 */}
|
||||
<TextInput
|
||||
style={styles.inviteCodeInput}
|
||||
placeholder={t('familyGroup.inviteCodePlaceholder')}
|
||||
placeholderTextColor="#9ca3af"
|
||||
value={inviteCodeInput}
|
||||
onChangeText={(text) => setInviteCodeInput(text.toUpperCase())}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
keyboardType="default"
|
||||
maxLength={12}
|
||||
/>
|
||||
|
||||
{/* 关系选择标签 */}
|
||||
<Text style={styles.relationshipLabel}>{t('familyGroup.relationshipLabel')}</Text>
|
||||
|
||||
{/* 关系选项网格 - 固定高度可滚动 */}
|
||||
<ScrollView
|
||||
style={styles.relationshipScrollView}
|
||||
contentContainerStyle={styles.relationshipGrid}
|
||||
showsVerticalScrollIndicator={true}
|
||||
nestedScrollEnabled
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{relationshipOptions.map((option) => {
|
||||
const isSelected = selectedRelationship === option.key;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.key}
|
||||
style={[
|
||||
styles.relationshipChip,
|
||||
isSelected && styles.relationshipChipSelected,
|
||||
]}
|
||||
onPress={() => setSelectedRelationship(option.key)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.relationshipChipText,
|
||||
isSelected && styles.relationshipChipTextSelected,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{joinError && joinModalVisible ? (
|
||||
<Text style={styles.modalError}>{joinError}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
topSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
avatarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
miniAvatarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#5B4CFF',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingRight: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
miniAvatar: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
marginRight: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFF',
|
||||
},
|
||||
miniAvatarName: {
|
||||
color: '#FFF',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
addButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#FFFFFF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
actionButtonsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
inviteBanner: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
shadowColor: '#5B4CFF',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
inviteContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inviteIconContainer: {
|
||||
marginRight: 8,
|
||||
},
|
||||
inviteText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: '#1F2138',
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentControl: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
segmentItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
segmentIconPlaceholder: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F3F4F6',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
segmentIconActive: {
|
||||
backgroundColor: '#E0E7FF',
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: '#5B4CFF',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
privacyNoticeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 32,
|
||||
marginBottom: 16,
|
||||
},
|
||||
privacyIconWrapper: {
|
||||
marginRight: 6,
|
||||
},
|
||||
privacyNoticeText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
},
|
||||
joinButtonGlass: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
minWidth: 60,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: 'rgba(255,255,255,0.45)',
|
||||
},
|
||||
joinButtonLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#0f1528',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
joinButtonFallback: {
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
// 加入家庭组弹窗样式
|
||||
joinModalContent: {
|
||||
gap: 12,
|
||||
},
|
||||
inviteCodeInput: {
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 2,
|
||||
color: '#0f1528',
|
||||
textAlign: 'center',
|
||||
},
|
||||
relationshipLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginTop: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
relationshipScrollView: {
|
||||
maxHeight: 160,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#fafafa',
|
||||
},
|
||||
relationshipGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
},
|
||||
relationshipChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
relationshipChipSelected: {
|
||||
backgroundColor: '#ede9fe',
|
||||
borderColor: '#8b5cf6',
|
||||
},
|
||||
relationshipChipText: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
fontWeight: '500',
|
||||
},
|
||||
relationshipChipTextSelected: {
|
||||
color: '#7c3aed',
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalError: {
|
||||
marginTop: 6,
|
||||
fontSize: 12,
|
||||
color: '#ef4444',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
1007
app/medications/ai-camera.tsx
Normal file
1007
app/medications/ai-camera.tsx
Normal file
File diff suppressed because it is too large
Load Diff
521
app/medications/ai-progress.tsx
Normal file
521
app/medications/ai-progress.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors, palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
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 STEP_KEYS: MedicationRecognitionTask['status'][] = [
|
||||
'analyzing_product',
|
||||
'analyzing_suitability',
|
||||
'analyzing_ingredients',
|
||||
'analyzing_effects',
|
||||
];
|
||||
|
||||
export default function MedicationAiProgressScreen() {
|
||||
const { t } = useI18n();
|
||||
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 steps = useMemo(() => STEP_KEYS.map(key => ({
|
||||
key,
|
||||
label: t(`medications.aiProgress.steps.${key}`)
|
||||
})), [t]);
|
||||
|
||||
const currentStepIndex = useMemo(() => {
|
||||
if (!task) return 0;
|
||||
const idx = STEP_KEYS.indexOf(task.status as any);
|
||||
if (idx >= 0) return idx;
|
||||
if (task.status === 'completed') return STEP_KEYS.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 || t('medications.aiProgress.errors.default'));
|
||||
setShowErrorModal(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[MEDICATION_AI] status failed', err);
|
||||
setError(err?.message || t('medications.aiProgress.errors.queryFailed'));
|
||||
} 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 / steps.length) * 100 + 10);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={[palette.gray[25], palette.gray[50]]} style={StyleSheet.absoluteFill} />
|
||||
<HeaderBar title={t('medications.aiProgress.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={[Colors.light.primary + '4D', Colors.light.accentPurple + '33', '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}>
|
||||
{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]}>{t('medications.aiProgress.steps.completed')}</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}>{t('medications.aiProgress.modal.title')}</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={Colors.light.primary}
|
||||
isInteractive={true}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[Colors.light.primary, Colors.light.accentPurple]}
|
||||
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}>{t('medications.aiProgress.modal.retry')}</Text>
|
||||
</LinearGradient>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={styles.retryButton}>
|
||||
<LinearGradient
|
||||
colors={[Colors.light.primary, Colors.light.accentPurple]}
|
||||
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}>{t('medications.aiProgress.modal.retry')}</Text>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
heroCard: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: 24,
|
||||
borderRadius: 24,
|
||||
backgroundColor: Colors.light.card,
|
||||
padding: 16,
|
||||
shadowColor: Colors.light.text,
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
},
|
||||
heroImageWrapper: {
|
||||
height: 230,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: palette.gray[50],
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
heroPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: palette.gray[50],
|
||||
},
|
||||
// 深色蒙版层,让点阵更清晰可见
|
||||
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: Colors.light.background,
|
||||
shadowColor: Colors.light.primary,
|
||||
shadowOpacity: 0.9,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
},
|
||||
progressRow: {
|
||||
height: 8,
|
||||
backgroundColor: palette.gray[50],
|
||||
borderRadius: 10,
|
||||
marginTop: 14,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
backgroundColor: Colors.light.primary,
|
||||
},
|
||||
progressText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
textAlign: 'right',
|
||||
},
|
||||
stepList: {
|
||||
marginTop: 24,
|
||||
marginHorizontal: 24,
|
||||
gap: 14,
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
bullet: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: palette.gray[50],
|
||||
},
|
||||
bulletActive: {
|
||||
backgroundColor: Colors.light.primary,
|
||||
},
|
||||
bulletDone: {
|
||||
backgroundColor: Colors.light.success,
|
||||
},
|
||||
stepLabel: {
|
||||
fontSize: 15,
|
||||
color: Colors.light.textMuted,
|
||||
},
|
||||
stepLabelActive: {
|
||||
color: Colors.light.text,
|
||||
fontWeight: '700',
|
||||
},
|
||||
stepLabelDone: {
|
||||
color: Colors.light.successDark,
|
||||
fontWeight: '700',
|
||||
},
|
||||
loadingBox: {
|
||||
marginTop: 30,
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: Colors.light.danger,
|
||||
fontSize: 14,
|
||||
},
|
||||
// Modal 样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||||
},
|
||||
errorModalContainer: {
|
||||
width: SCREEN_WIDTH - 48,
|
||||
backgroundColor: Colors.light.card,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
shadowColor: Colors.light.primary,
|
||||
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: palette.purple[50],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
errorModalTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
color: Colors.light.text,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorMessageBox: {
|
||||
backgroundColor: palette.purple[25],
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 28,
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: palette.purple[200],
|
||||
},
|
||||
errorMessageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
color: Colors.light.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: Colors.light.primary,
|
||||
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: Colors.light.onPrimary,
|
||||
},
|
||||
});
|
||||
886
app/medications/ai-summary.tsx
Normal file
886
app/medications/ai-summary.tsx
Normal file
@@ -0,0 +1,886 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { getMedicationAiSummary } from '@/services/medications';
|
||||
import { type MedicationAiSummary, type MedicationAiSummaryItem } from '@/types/medication';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
export default function MedicationAiSummaryScreen() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [summary, setSummary] = useState<MedicationAiSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<string>('');
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
const [showCompletionInfoModal, setShowCompletionInfoModal] = useState(false);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getMedicationAiSummary();
|
||||
setSummary(data);
|
||||
setLastUpdated(dayjs().format('YYYY.MM.DD HH:mm'));
|
||||
} catch (err: any) {
|
||||
const status = err?.status;
|
||||
if (status === 403) {
|
||||
setError(t('medications.aiSummary.error403'));
|
||||
} else {
|
||||
setError(err?.message || t('medications.aiSummary.genericError'));
|
||||
}
|
||||
setSummary(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchSummary();
|
||||
}, [fetchSummary])
|
||||
);
|
||||
|
||||
const handleExplainRefresh = useCallback(() => {
|
||||
setShowInfoModal(true);
|
||||
}, []);
|
||||
|
||||
const handleExplainCompletion = useCallback(() => {
|
||||
setShowCompletionInfoModal(true);
|
||||
}, []);
|
||||
|
||||
const medicationItems = summary?.medicationAnalysis ?? [];
|
||||
const isEmpty = !loading && !error && medicationItems.length === 0;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const plannedDoses = medicationItems.reduce((acc, item) => acc + (item.plannedDoses || 0), 0);
|
||||
const takenDoses = medicationItems.reduce((acc, item) => acc + (item.takenDoses || 0), 0);
|
||||
const completion = plannedDoses > 0 ? takenDoses / plannedDoses : 0;
|
||||
const avgCompletion =
|
||||
medicationItems.length > 0
|
||||
? medicationItems.reduce((acc, item) => acc + (item.completionRate || 0), 0) /
|
||||
medicationItems.length
|
||||
: 0;
|
||||
const plannedDays = medicationItems.reduce((acc, item) => acc + (item.plannedDays || 0), 0);
|
||||
|
||||
return {
|
||||
plannedDoses,
|
||||
takenDoses,
|
||||
completion,
|
||||
avgCompletion,
|
||||
plannedDays,
|
||||
activePlans: medicationItems.length,
|
||||
};
|
||||
}, [medicationItems]);
|
||||
|
||||
const completionPercent = Math.min(100, Math.round(stats.completion * 100));
|
||||
|
||||
const renderMedicationCard = (item: MedicationAiSummaryItem) => {
|
||||
const percent = Math.min(100, Math.round((item.completionRate || 0) * 100));
|
||||
return (
|
||||
<View key={item.id} style={styles.planCard}>
|
||||
<View style={styles.planHeader}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<ThemedText style={styles.planName}>{item.name}</ThemedText>
|
||||
<ThemedText style={styles.planMeta}>
|
||||
{t('medications.aiSummary.daysLabel', {
|
||||
days: item.plannedDays,
|
||||
times: item.timesPerDay,
|
||||
})}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.planChip}>
|
||||
<IconSymbol name="sparkles" size={14} color="#d6b37f" />
|
||||
<ThemedText style={styles.planChipText}>
|
||||
{t('medications.aiSummary.badges.adherence')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressRow}>
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${percent}%` }]} />
|
||||
</View>
|
||||
<ThemedText style={styles.progressValue}>
|
||||
{t('medications.aiSummary.completionLabel', { value: percent })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.planFooter}>
|
||||
<ThemedText style={styles.planStat}>
|
||||
{t('medications.aiSummary.doseSummary', {
|
||||
taken: item.takenDoses,
|
||||
planned: item.plannedDoses,
|
||||
})}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.planDate}>
|
||||
{dayjs(item.startDate).format('YYYY.MM.DD')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const headerTitle = (
|
||||
<View style={styles.headerTitle}>
|
||||
<ThemedText style={styles.title}>{t('medications.aiSummary.title')}</ThemedText>
|
||||
<ThemedText style={styles.subtitle}>{t('medications.aiSummary.subtitle')}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#0a0e16', '#0b101a', '#0b0f16']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.glowTop} />
|
||||
<View style={styles.glowBottom} />
|
||||
|
||||
<HeaderBar
|
||||
title={headerTitle}
|
||||
tone="dark"
|
||||
transparent
|
||||
variant="minimal"
|
||||
right={
|
||||
<TouchableOpacity
|
||||
style={styles.iconButton}
|
||||
onPress={handleExplainRefresh}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<IconSymbol name="info.circle" size={20} color="#dfe8ff" />
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: insets.bottom + 32, paddingTop: insets.top + 80 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#131a28', '#0f1623']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.heroCard}
|
||||
>
|
||||
<View style={styles.heroHeader}>
|
||||
<ThemedText style={styles.heroLabel}>
|
||||
{t('medications.aiSummary.overviewTitle')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.updatedAt}>
|
||||
{lastUpdated ? t('medications.aiSummary.updatedAt', { time: lastUpdated }) : ' '}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroMainRow}>
|
||||
<View style={styles.heroLeft}>
|
||||
<ThemedText style={styles.heroValue}>{completionPercent}%</ThemedText>
|
||||
<ThemedText style={styles.heroCaption}>
|
||||
{t('medications.aiSummary.doseSummary', {
|
||||
taken: stats.takenDoses,
|
||||
planned: stats.plannedDoses,
|
||||
})}
|
||||
</ThemedText>
|
||||
<View style={styles.heroProgressTrack}>
|
||||
<View style={[styles.heroProgressFill, { width: `${completionPercent}%` }]} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.heroChip}>
|
||||
<ThemedText style={styles.heroChipLabel}>
|
||||
{t('medications.aiSummary.badges.safety')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.heroChipValue}>{stats.activePlans}</ThemedText>
|
||||
<ThemedText style={styles.heroChipHint}>
|
||||
{t('medications.aiSummary.stats.activePlans')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.heroStatsRow}>
|
||||
<View style={styles.heroStatItem}>
|
||||
<ThemedText style={styles.heroStatLabel}>
|
||||
{t('medications.aiSummary.stats.avgCompletion')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.heroStatValue}>
|
||||
{Math.round(stats.avgCompletion * 100)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.heroStatItem}>
|
||||
<ThemedText style={styles.heroStatLabel}>
|
||||
{t('medications.aiSummary.stats.activeDays')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.heroStatValue}>{stats.plannedDays}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.heroStatItem}>
|
||||
<ThemedText style={styles.heroStatLabel}>
|
||||
{t('medications.aiSummary.stats.takenDoses')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.heroStatValue}>{stats.takenDoses}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
||||
{error ? (
|
||||
<View style={styles.errorCard}>
|
||||
<ThemedText style={styles.errorTitle}>{error}</ThemedText>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={fetchSummary} activeOpacity={0.85}>
|
||||
<ThemedText style={styles.retryText}>{t('medications.aiSummary.retry')}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText style={styles.sectionTitle}>
|
||||
{t('medications.aiSummary.keyInsights')}
|
||||
</ThemedText>
|
||||
<View style={styles.pillChip}>
|
||||
<IconSymbol name="sparkles" size={14} color="#0b0f16" />
|
||||
<ThemedText style={styles.pillChipText}>
|
||||
{t('medications.aiSummary.pillChip')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
<ThemedText style={styles.insightText}>
|
||||
{summary?.keyInsights || t('medications.aiSummary.keyInsightPlaceholder')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionCard}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText style={styles.sectionTitle}>
|
||||
{t('medications.aiSummary.listTitle')}
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
style={styles.infoIconButton}
|
||||
onPress={handleExplainCompletion}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<IconSymbol name="info.circle" size={16} color="#8b94a8" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{loading ? (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator color="#d6b37f" />
|
||||
<ThemedText style={styles.loadingText}>
|
||||
{t('medications.aiSummary.refresh')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : isEmpty ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ThemedText style={styles.emptyTitle}>
|
||||
{t('medications.aiSummary.emptyTitle')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.emptySubtitle}>
|
||||
{t('medications.aiSummary.emptyDescription')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.planList}>{medicationItems.map(renderMedicationCard)}</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={showInfoModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowInfoModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.infoOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowInfoModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={styles.infoModal}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#111827', '#0b1220']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.infoGradient}
|
||||
>
|
||||
<View style={styles.infoHeader}>
|
||||
<ThemedText style={styles.infoBadge}>{t('medications.aiSummary.infoModal.badge')}</ThemedText>
|
||||
<ThemedText style={styles.infoTitle}>{t('medications.aiSummary.infoModal.title')}</ThemedText>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowInfoModal(false)}
|
||||
style={styles.infoClose}
|
||||
accessibilityLabel="close"
|
||||
>
|
||||
<IconSymbol name="xmark" size={18} color="#e5e7eb" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.infoModal.point1')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.infoModal.point2')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.infoModal.point3')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.infoModal.point4')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoButtonContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowInfoModal(false)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#d6b37f', '#c59b63']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.infoButton}
|
||||
>
|
||||
<Text style={styles.infoButtonText}>{t('medications.aiSummary.infoModal.button')}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
visible={showCompletionInfoModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowCompletionInfoModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.infoOverlay}
|
||||
activeOpacity={1}
|
||||
onPress={() => setShowCompletionInfoModal(false)}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
style={styles.infoModal}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#111827', '#0b1220']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.infoGradient}
|
||||
>
|
||||
<View style={styles.infoHeader}>
|
||||
<ThemedText style={styles.infoBadge}>{t('medications.aiSummary.completionInfoModal.badge')}</ThemedText>
|
||||
<ThemedText style={styles.infoTitle}>{t('medications.aiSummary.completionInfoModal.title')}</ThemedText>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowCompletionInfoModal(false)}
|
||||
style={styles.infoClose}
|
||||
accessibilityLabel="close"
|
||||
>
|
||||
<IconSymbol name="xmark" size={18} color="#e5e7eb" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.infoContent}>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.completionInfoModal.point1')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.completionInfoModal.point2')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.completionInfoModal.point3')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.completionInfoModal.point4')}
|
||||
</Text>
|
||||
<Text style={styles.infoText}>
|
||||
{t('medications.aiSummary.completionInfoModal.point5')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoButtonContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowCompletionInfoModal(false)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#d6b37f', '#c59b63']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.infoButton}
|
||||
>
|
||||
<Text style={styles.infoButtonText}>{t('medications.aiSummary.completionInfoModal.button')}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0b0f16',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 20,
|
||||
gap: 20,
|
||||
},
|
||||
glowTop: {
|
||||
position: 'absolute',
|
||||
top: -80,
|
||||
left: -40,
|
||||
width: 200,
|
||||
height: 200,
|
||||
backgroundColor: '#1b2a44',
|
||||
opacity: 0.35,
|
||||
borderRadius: 140,
|
||||
},
|
||||
glowBottom: {
|
||||
position: 'absolute',
|
||||
bottom: -120,
|
||||
right: -60,
|
||||
width: 240,
|
||||
height: 240,
|
||||
backgroundColor: '#123125',
|
||||
opacity: 0.25,
|
||||
borderRadius: 200,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
headerTitle: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: 6,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#d6b37f',
|
||||
},
|
||||
badgeText: {
|
||||
color: '#0b0f16',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
title: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 22,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
subtitle: {
|
||||
color: '#b9c2d3',
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
heroCard: {
|
||||
borderRadius: 24,
|
||||
padding: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 16,
|
||||
gap: 14,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
heroLabel: {
|
||||
color: '#f5f6fb',
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
updatedAt: {
|
||||
color: '#8b94a8',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
heroMainRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
heroLeft: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
heroValue: {
|
||||
color: '#36d0a5',
|
||||
fontSize: 38,
|
||||
lineHeight: 42,
|
||||
fontFamily: 'AliBold',
|
||||
letterSpacing: 0.5,
|
||||
flexShrink: 1,
|
||||
},
|
||||
heroCaption: {
|
||||
color: '#c2ccdf',
|
||||
fontSize: 13,
|
||||
fontFamily: 'AliRegular',
|
||||
marginTop: 4,
|
||||
},
|
||||
heroProgressTrack: {
|
||||
marginTop: 12,
|
||||
height: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
heroProgressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#36d0a5',
|
||||
},
|
||||
heroChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(214, 179, 127, 0.12)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(214, 179, 127, 0.3)',
|
||||
minWidth: 120,
|
||||
alignItems: 'flex-start',
|
||||
gap: 4,
|
||||
},
|
||||
heroChipLabel: {
|
||||
color: '#d6b37f',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
heroChipValue: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 20,
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 24,
|
||||
},
|
||||
heroChipHint: {
|
||||
color: '#b9c2d3',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
heroStatsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
heroStatItem: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
heroStatLabel: {
|
||||
color: '#9dabc4',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
heroStatValue: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 18,
|
||||
marginTop: 6,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sectionCard: {
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
gap: 12,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
sectionTitle: {
|
||||
color: '#f5f6fb',
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
pillChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: '#d6b37f',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
pillChipText: {
|
||||
color: '#0b0f16',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
insightText: {
|
||||
color: '#d9e2f2',
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
planList: {
|
||||
gap: 12,
|
||||
},
|
||||
planCard: {
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.06)',
|
||||
gap: 10,
|
||||
},
|
||||
planHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
planName: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
planMeta: {
|
||||
color: '#9dabc4',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
marginTop: 2,
|
||||
},
|
||||
planChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(214, 179, 127, 0.15)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(214, 179, 127, 0.35)',
|
||||
},
|
||||
planChipText: {
|
||||
color: '#d6b37f',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
progressRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
progressTrack: {
|
||||
flex: 1,
|
||||
height: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#36d0a5',
|
||||
borderRadius: 10,
|
||||
},
|
||||
progressValue: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
planFooter: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
planStat: {
|
||||
color: '#c7d1e4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
planDate: {
|
||||
color: '#7f8aa4',
|
||||
fontSize: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
errorCard: {
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255, 86, 86, 0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 86, 86, 0.3)',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
errorTitle: {
|
||||
color: '#ff9c9c',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#ff9c9c',
|
||||
},
|
||||
retryText: {
|
||||
color: '#0b0f16',
|
||||
fontSize: 13,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
loadingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
loadingText: {
|
||||
color: '#c7d1e4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: 12,
|
||||
gap: 6,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 15,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtitle: {
|
||||
color: '#9dabc4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'AliRegular',
|
||||
lineHeight: 20,
|
||||
},
|
||||
infoOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
infoModal: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
infoGradient: {
|
||||
padding: 24,
|
||||
gap: 20,
|
||||
},
|
||||
infoHeader: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
infoBadge: {
|
||||
color: '#d6b37f',
|
||||
fontSize: 24,
|
||||
lineHeight: 28,
|
||||
fontFamily: 'AliBold',
|
||||
marginBottom: 10,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
infoTitle: {
|
||||
color: '#f6f7fb',
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
infoClose: {
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: -4,
|
||||
padding: 8,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
infoContent: {
|
||||
gap: 14,
|
||||
},
|
||||
infoText: {
|
||||
color: '#d9e2f2',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
infoButtonContainer: {
|
||||
marginTop: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoButtonWrapper: {
|
||||
// minWidth: 120,
|
||||
// maxWidth: 180,
|
||||
},
|
||||
infoButton: {
|
||||
borderRadius: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 28,
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
infoButtonGlass: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 28,
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoButtonText: {
|
||||
color: '#0b0f16',
|
||||
fontSize: 15,
|
||||
fontFamily: 'AliBold',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
infoIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(139, 148, 168, 0.1)',
|
||||
},
|
||||
});
|
||||
@@ -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,5 +1,6 @@
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useMoodData } from '@/hooks/useMoodData';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMoodOptions } from '@/services/moodCheckins';
|
||||
@@ -61,6 +62,7 @@ const generateCalendarData = (targetDate: Date) => {
|
||||
};
|
||||
|
||||
export default function MoodCalendarScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const params = useLocalSearchParams();
|
||||
const { fetchMoodRecords, fetchMoodHistoryRecords } = useMoodData();
|
||||
@@ -89,9 +91,30 @@ export default function MoodCalendarScreen() {
|
||||
return selectLatestMoodRecordByDate(selectedDateString)(state);
|
||||
});
|
||||
|
||||
const moodOptions = getMoodOptions();
|
||||
const weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
|
||||
const moodOptions = getMoodOptions(t);
|
||||
const weekDays = [
|
||||
t('mood.calendar.weekDays.monday'),
|
||||
t('mood.calendar.weekDays.tuesday'),
|
||||
t('mood.calendar.weekDays.wednesday'),
|
||||
t('mood.calendar.weekDays.thursday'),
|
||||
t('mood.calendar.weekDays.friday'),
|
||||
t('mood.calendar.weekDays.saturday'),
|
||||
t('mood.calendar.weekDays.sunday'),
|
||||
];
|
||||
const monthNames = [
|
||||
t('mood.calendar.months.january'),
|
||||
t('mood.calendar.months.february'),
|
||||
t('mood.calendar.months.march'),
|
||||
t('mood.calendar.months.april'),
|
||||
t('mood.calendar.months.may'),
|
||||
t('mood.calendar.months.june'),
|
||||
t('mood.calendar.months.july'),
|
||||
t('mood.calendar.months.august'),
|
||||
t('mood.calendar.months.september'),
|
||||
t('mood.calendar.months.october'),
|
||||
t('mood.calendar.months.november'),
|
||||
t('mood.calendar.months.december'),
|
||||
];
|
||||
|
||||
// 生成当前月份的日历数据
|
||||
const { calendar, today, month, year } = generateCalendarData(currentMonth);
|
||||
@@ -103,7 +126,7 @@ export default function MoodCalendarScreen() {
|
||||
const endDate = dayjs(targetMonth).endOf('month').format('YYYY-MM-DD');
|
||||
await fetchMoodHistoryRecordsRef.current({ startDate, endDate });
|
||||
} catch (error) {
|
||||
console.error('加载月份心情数据失败:', error);
|
||||
console.error(t('mood.calendar.errors.loadMonthDataFailed'), error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -112,7 +135,7 @@ export default function MoodCalendarScreen() {
|
||||
try {
|
||||
await fetchMoodRecordsRef.current(dateString);
|
||||
} catch (error) {
|
||||
console.error('加载心情记录失败:', error);
|
||||
console.error(t('mood.calendar.errors.loadDailyDataFailed'), error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -235,7 +258,7 @@ export default function MoodCalendarScreen() {
|
||||
|
||||
<View style={styles.safeArea}>
|
||||
<HeaderBar
|
||||
title="心情日历"
|
||||
title={t('mood.calendar.title')}
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
transparent={true}
|
||||
@@ -255,7 +278,7 @@ export default function MoodCalendarScreen() {
|
||||
>
|
||||
<Text style={styles.navButtonText}>‹</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.monthTitle}>{year}年{monthNames[month - 1]}</Text>
|
||||
<Text style={styles.monthTitle}>{year} {monthNames[month - 1]}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.navButton}
|
||||
onPress={goToNextMonth}
|
||||
@@ -315,13 +338,13 @@ export default function MoodCalendarScreen() {
|
||||
<View style={styles.selectedDateSection}>
|
||||
<View style={styles.selectedDateHeader}>
|
||||
<Text style={styles.selectedDateTitle}>
|
||||
{selectedDay ? dayjs(currentMonth).date(selectedDay).format('YYYY年M月D日') : '请选择日期'}
|
||||
{selectedDay ? dayjs(currentMonth).date(selectedDay).format(t('mood.calendar.selectedDate.dateFormat')) : t('mood.calendar.selectedDate.selectDate')}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addMoodButton}
|
||||
onPress={openMoodEdit}
|
||||
>
|
||||
<Text style={styles.addMoodButtonText}>记录</Text>
|
||||
<Text style={styles.addMoodButtonText}>{t('mood.calendar.selectedDate.record')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -343,7 +366,7 @@ export default function MoodCalendarScreen() {
|
||||
<Text style={styles.recordMood}>
|
||||
{moodOptions.find(m => m.type === selectedDateMood.moodType)?.label}
|
||||
</Text>
|
||||
<Text style={styles.recordIntensity}>强度: {selectedDateMood.intensity}</Text>
|
||||
<Text style={styles.recordIntensity}>{t('mood.calendar.selectedDate.intensity')}: {selectedDateMood.intensity}</Text>
|
||||
{selectedDateMood.description && (
|
||||
<Text style={styles.recordDescription}>{selectedDateMood.description}</Text>
|
||||
)}
|
||||
@@ -355,14 +378,14 @@ export default function MoodCalendarScreen() {
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.emptyRecord}>
|
||||
<Text style={styles.emptyRecordText}>暂无心情记录</Text>
|
||||
<Text style={styles.emptyRecordSubtext}>点击右上角"记录"按钮添加心情</Text>
|
||||
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noRecord')}</Text>
|
||||
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noRecordHint')}</Text>
|
||||
</View>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.emptyRecord}>
|
||||
<Text style={styles.emptyRecordText}>请先选择一个日期</Text>
|
||||
<Text style={styles.emptyRecordSubtext}>点击日历中的日期,然后点击"记录"按钮添加心情</Text>
|
||||
<Text style={styles.emptyRecordText}>{t('mood.calendar.selectedDate.noDateSelected')}</Text>
|
||||
<Text style={styles.emptyRecordSubtext}>{t('mood.calendar.selectedDate.noDateSelectedHint')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMoodOptions, MoodType } from '@/services/moodCheckins';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function MoodEditScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
@@ -51,7 +53,7 @@ export default function MoodEditScreen() {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
|
||||
const moodOptions = getMoodOptions();
|
||||
const moodOptions = getMoodOptions(t);
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const moodRecords = useAppSelector(selectMoodRecordsByDate(selectedDate));
|
||||
@@ -95,7 +97,7 @@ export default function MoodEditScreen() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMood) {
|
||||
Alert.alert('提示', '请选择心情');
|
||||
Alert.alert(t('common.alert'), t('mood.edit.alerts.selectMood'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,12 +122,12 @@ export default function MoodEditScreen() {
|
||||
})).unwrap();
|
||||
}
|
||||
|
||||
Alert.alert('成功', existingMood ? '心情记录已更新' : '心情记录已保存', [
|
||||
{ text: '确定', onPress: () => router.back() }
|
||||
Alert.alert(t('common.success'), existingMood ? t('mood.edit.alerts.updateSuccess') : t('mood.edit.alerts.saveSuccess'), [
|
||||
{ text: t('common.confirm'), onPress: () => router.back() }
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('保存心情失败:', error);
|
||||
Alert.alert('错误', '保存心情失败,请重试');
|
||||
Alert.alert(t('common.error'), t('mood.edit.alerts.saveError'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -135,24 +137,24 @@ export default function MoodEditScreen() {
|
||||
if (!existingMood) return;
|
||||
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条心情记录吗?',
|
||||
t('mood.edit.alerts.confirmDeleteTitle'),
|
||||
t('mood.edit.alerts.confirmDelete'),
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
text: t('common.delete'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await dispatch(deleteMoodRecord({ id: existingMood.id })).unwrap();
|
||||
|
||||
Alert.alert('成功', '心情记录已删除', [
|
||||
{ text: '确定', onPress: () => router.back() }
|
||||
Alert.alert(t('common.success'), t('mood.edit.alerts.deleteSuccess'), [
|
||||
{ text: t('common.confirm'), onPress: () => router.back() }
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('删除心情失败:', error);
|
||||
Alert.alert('错误', '删除心情失败,请重试');
|
||||
Alert.alert(t('common.error'), t('mood.edit.alerts.deleteError'));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -183,7 +185,7 @@ export default function MoodEditScreen() {
|
||||
<View style={styles.decorativeCircle2} />
|
||||
<View style={styles.safeArea} >
|
||||
<HeaderBar
|
||||
title={existingMood ? '编辑心情' : '记录心情'}
|
||||
title={existingMood ? t('mood.edit.editTitle') : t('mood.edit.title')}
|
||||
onBack={() => router.back()}
|
||||
withSafeTop={false}
|
||||
transparent={true}
|
||||
@@ -207,13 +209,13 @@ export default function MoodEditScreen() {
|
||||
{/* 日期显示 */}
|
||||
<View style={styles.dateSection}>
|
||||
<Text style={styles.dateTitle}>
|
||||
{dayjs(selectedDate).format('YYYY年M月D日')}
|
||||
{dayjs(selectedDate).format(t('mood.edit.dateFormat'))}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 心情选择 */}
|
||||
<View style={styles.moodSection}>
|
||||
<Text style={styles.sectionTitle}>选择心情</Text>
|
||||
<Text style={styles.sectionTitle}>{t('mood.edit.selectMood')}</Text>
|
||||
<View style={styles.moodOptions}>
|
||||
{moodOptions.map((mood, index) => (
|
||||
<TouchableOpacity
|
||||
@@ -233,7 +235,7 @@ export default function MoodEditScreen() {
|
||||
|
||||
{/* 心情强度选择 */}
|
||||
<View style={styles.intensitySection}>
|
||||
<Text style={styles.sectionTitle}>心情强度</Text>
|
||||
<Text style={styles.sectionTitle}>{t('mood.edit.intensity')}</Text>
|
||||
<MoodIntensitySlider
|
||||
value={intensity}
|
||||
onValueChange={handleIntensityChange}
|
||||
@@ -248,18 +250,12 @@ export default function MoodEditScreen() {
|
||||
{/* 心情描述 */}
|
||||
|
||||
<View style={styles.descriptionSection}>
|
||||
<Text style={styles.sectionTitle}>心情日记</Text>
|
||||
<Text style={styles.diarySubtitle}>记录你的心情,珍藏美好回忆</Text>
|
||||
<Text style={styles.sectionTitle}>{t('mood.edit.diary')}</Text>
|
||||
<Text style={styles.diarySubtitle}>{t('mood.edit.diarySubtitle')}</Text>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
style={styles.descriptionInput}
|
||||
placeholder={`今天的心情如何?
|
||||
|
||||
你经历过什么特别的事情吗?
|
||||
有什么让你开心的事?
|
||||
或者,有什么让你感到困扰?
|
||||
|
||||
写下你的感受,让这些时刻成为你珍贵的记忆...`}
|
||||
placeholder={t('mood.edit.placeholder')}
|
||||
placeholderTextColor="#a8a8a8"
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
@@ -289,7 +285,7 @@ export default function MoodEditScreen() {
|
||||
disabled={!selectedMood || isLoading}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{isLoading ? '保存中...' : existingMood ? '更新心情' : '保存心情'}
|
||||
{isLoading ? t('mood.edit.saving') : existingMood ? t('mood.edit.update') : t('mood.edit.save')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{existingMood && (
|
||||
|
||||
@@ -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,7 +5,9 @@ import { NutritionRecordCard } from '@/components/NutritionRecordCard';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { type FoodRecognitionResponse } from '@/services/foodRecognition';
|
||||
@@ -19,16 +21,20 @@ import {
|
||||
selectNutritionRecordsByDate,
|
||||
selectNutritionSummaryByDate
|
||||
} from '@/store/nutritionSlice';
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchBasalEnergyBurned } from '@/utils/health';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
@@ -38,24 +44,21 @@ import {
|
||||
type ViewMode = 'daily' | 'all';
|
||||
|
||||
export default function NutritionRecordsScreen() {
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const dispatch = useAppDispatch();
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 日期相关状态 - 使用与统计页面相同的日期逻辑
|
||||
const days = getMonthDaysZh();
|
||||
const { isLoggedIn } = useAuthGuard();
|
||||
|
||||
// 日期相关状态
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const monthTitle = getMonthTitleZh();
|
||||
// 直接使用 state 管理当前选中日期,而不是从 days 数组派生,以支持 DateSelector 内部月份切换
|
||||
const [currentSelectedDate, setCurrentSelectedDate] = useState<Date>(new Date());
|
||||
|
||||
// 获取当前选中日期 - 使用 useMemo 避免每次渲染都创建新对象
|
||||
const currentSelectedDate = useMemo(() => {
|
||||
return days[selectedIndex]?.date?.toDate() ?? new Date();
|
||||
}, [selectedIndex, days]);
|
||||
|
||||
const currentSelectedDateString = useMemo(() => {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
const currentSelectedDateString = dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
|
||||
// 从 Redux 获取数据
|
||||
const healthData = useAppSelector(selectHealthDataByDate(currentSelectedDateString));
|
||||
@@ -86,11 +89,11 @@ export default function NutritionRecordsScreen() {
|
||||
const displayRecords = viewMode === 'daily' ? nutritionRecords : allRecords;
|
||||
const loading = viewMode === 'daily' ? nutritionLoading.records : allRecordsLoading;
|
||||
|
||||
|
||||
// 页面聚焦时自动刷新数据
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
console.log('营养记录页面聚焦,刷新数据...');
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
if (viewMode === 'daily') {
|
||||
dispatch(fetchDailyNutritionData(currentSelectedDate));
|
||||
} else {
|
||||
@@ -119,7 +122,7 @@ export default function NutritionRecordsScreen() {
|
||||
|
||||
loadAllRecords();
|
||||
}
|
||||
}, [viewMode, currentSelectedDateString, dispatch])
|
||||
}, [viewMode, currentSelectedDateString, dispatch, isLoggedIn])
|
||||
);
|
||||
|
||||
// 当选中日期或视图模式变化时重新加载数据
|
||||
@@ -323,71 +326,6 @@ export default function NutritionRecordsScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// 渲染日期选择器(仅在按天查看模式下显示)
|
||||
const renderDateSelector = () => {
|
||||
if (viewMode !== 'daily') return null;
|
||||
|
||||
return (
|
||||
<DateSelector
|
||||
selectedIndex={selectedIndex}
|
||||
onDateSelect={(index, date) => setSelectedIndex(index)}
|
||||
showMonthTitle={true}
|
||||
disableFutureDates={true}
|
||||
showCalendarIcon={true}
|
||||
containerStyle={{
|
||||
paddingHorizontal: 16
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={styles.emptyContent}>
|
||||
<Ionicons name="restaurant-outline" size={48} color={colorTokens.textSecondary} />
|
||||
<Text style={[styles.emptyTitle, { color: colorTokens.text }]}>
|
||||
{viewMode === 'daily' ? '今天还没有记录' : '暂无营养记录'}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: colorTokens.textSecondary }]}>
|
||||
{viewMode === 'daily' ? '开始记录今日营养摄入' : '开始记录你的营养摄入吧'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderRecord = ({ item, index }: { item: DietRecord; index: number }) => (
|
||||
<NutritionRecordCard
|
||||
record={item}
|
||||
onPress={() => handleRecordPress(item)}
|
||||
onDelete={() => handleDeleteRecord(item.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!hasMoreData) {
|
||||
return (
|
||||
<View style={styles.footerContainer}>
|
||||
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
|
||||
没有更多数据了
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'all' && displayRecords.length > 0) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||
加载更多
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 根据当前时间智能判断餐次类型
|
||||
const getCurrentMealType = (): 'breakfast' | 'lunch' | 'dinner' | 'snack' => {
|
||||
const hour = new Date().getHours();
|
||||
@@ -411,68 +349,160 @@ export default function NutritionRecordsScreen() {
|
||||
// 渲染右侧添加按钮
|
||||
const renderRightButton = () => (
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
onPress={handleAddFood}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={colorTokens.primary} />
|
||||
{isGlassAvailable ? (
|
||||
<GlassView
|
||||
style={styles.glassAddButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.4)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="add" size={24} color={colorTokens.primary} />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.fallbackAddButton, { backgroundColor: 'rgba(255,255,255,0.8)' }]}>
|
||||
<Ionicons name="add" size={24} color={colorTokens.primary} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<HeaderBar
|
||||
title="营养记录"
|
||||
onBack={() => router.back()}
|
||||
right={renderRightButton()}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
paddingTop: safeAreaTop
|
||||
}}>
|
||||
|
||||
{/* {renderViewModeToggle()} */}
|
||||
{renderDateSelector()}
|
||||
|
||||
{/* Calorie Ring Chart */}
|
||||
<CalorieRingChart
|
||||
metabolism={basalMetabolism}
|
||||
exercise={healthData?.activeEnergyBurned || 0}
|
||||
consumed={nutritionSummary?.totalCalories || 0}
|
||||
protein={nutritionSummary?.totalProtein || 0}
|
||||
fat={nutritionSummary?.totalFat || 0}
|
||||
carbs={nutritionSummary?.totalCarbohydrate || 0}
|
||||
proteinGoal={nutritionGoals.proteinGoal}
|
||||
fatGoal={nutritionGoals.fatGoal}
|
||||
carbsGoal={nutritionGoals.carbsGoal}
|
||||
const renderEmptyState = () => (
|
||||
<View style={styles.emptySimpleContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-yingyang.png')}
|
||||
style={styles.emptySimpleImage}
|
||||
contentFit="contain"
|
||||
/>
|
||||
<Text style={styles.emptySimpleText}>
|
||||
{t('nutritionRecords.empty.title')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleAddFood}>
|
||||
<Text style={[styles.emptyActionText, { color: colorTokens.primary }]}>
|
||||
{t('nutritionRecords.empty.action')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
{(
|
||||
<FlatList
|
||||
data={displayRecords}
|
||||
renderItem={({ item, index }) => renderRecord({ item, index })}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={[
|
||||
styles.listContainer,
|
||||
{ paddingBottom: 40, paddingTop: 16 }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colorTokens.primary}
|
||||
colors={[colorTokens.primary]}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
||||
onEndReachedThreshold={0.1}
|
||||
const renderRecord = ({ item }: { item: DietRecord }) => (
|
||||
<NutritionRecordCard
|
||||
record={item}
|
||||
onPress={() => handleRecordPress(item)}
|
||||
onDelete={() => handleDeleteRecord(item.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!hasMoreData) {
|
||||
if (displayRecords.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.footerContainer}>
|
||||
<Text style={[styles.footerText, { color: colorTokens.textSecondary }]}>
|
||||
{t('nutritionRecords.footer.end')}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'all' && displayRecords.length > 0) {
|
||||
return (
|
||||
<TouchableOpacity style={styles.loadMoreButton} onPress={loadMoreRecords}>
|
||||
<Text style={[styles.loadMoreText, { color: colorTokens.primary }]}>
|
||||
{t('nutritionRecords.footer.loadMore')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ListHeader = () => (
|
||||
<View>
|
||||
<View style={styles.headerContent}>
|
||||
{viewMode === 'daily' && (
|
||||
<DateSelector
|
||||
selectedIndex={selectedIndex}
|
||||
onDateSelect={(index, date) => {
|
||||
setSelectedIndex(index);
|
||||
setCurrentSelectedDate(date);
|
||||
}}
|
||||
showMonthTitle={true}
|
||||
disableFutureDates={true}
|
||||
showCalendarIcon={true}
|
||||
containerStyle={styles.dateSelectorContainer}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.chartWrapper}>
|
||||
<CalorieRingChart
|
||||
metabolism={basalMetabolism}
|
||||
exercise={healthData?.activeEnergyBurned || 0}
|
||||
consumed={nutritionSummary?.totalCalories || 0}
|
||||
protein={nutritionSummary?.totalProtein || 0}
|
||||
fat={nutritionSummary?.totalFat || 0}
|
||||
carbs={nutritionSummary?.totalCarbohydrate || 0}
|
||||
proteinGoal={nutritionGoals.proteinGoal}
|
||||
fatGoal={nutritionGoals.fatGoal}
|
||||
carbsGoal={nutritionGoals.carbsGoal}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.listTitleContainer}>
|
||||
<Text style={styles.listTitle}>{t('nutritionRecords.listTitle')}</Text>
|
||||
{displayRecords.length > 0 && (
|
||||
<Text style={styles.listSubtitle}>{t('nutritionRecords.recordCount', { count: displayRecords.length })}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: '#f3f4fb' }]}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
|
||||
{/* 顶部柔和渐变背景 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 243, 224, 0.8)', 'rgba(243, 244, 251, 0)']}
|
||||
style={styles.topGradient}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title={t('nutritionRecords.title')}
|
||||
onBack={() => router.back()}
|
||||
right={renderRightButton()}
|
||||
transparent={true}
|
||||
/>
|
||||
|
||||
<FlatList
|
||||
data={displayRecords}
|
||||
renderItem={renderRecord}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={[
|
||||
styles.listContainer,
|
||||
{ paddingTop: safeAreaTop }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colorTokens.primary}
|
||||
colors={[colorTokens.primary]}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={ListHeader}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={viewMode === 'all' ? loadMoreRecords : undefined}
|
||||
onEndReachedThreshold={0.1}
|
||||
/>
|
||||
|
||||
{/* 食物添加悬浮窗 */}
|
||||
<FloatingFoodOverlay
|
||||
@@ -488,130 +518,105 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
viewModeContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
},
|
||||
toggleContainer: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 20,
|
||||
padding: 2,
|
||||
},
|
||||
toggleButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
minWidth: 80,
|
||||
alignItems: 'center',
|
||||
},
|
||||
toggleText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
daysContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
daysScrollContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
dayPill: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 34,
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
dayNumber: {
|
||||
fontSize: 18,
|
||||
textAlign: 'center',
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
addButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
topGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: 320,
|
||||
},
|
||||
listContainer: {
|
||||
paddingBottom: 100, // 留出底部空间防止遮挡
|
||||
},
|
||||
headerContent: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
dateSelectorContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
paddingHorizontal: 16,
|
||||
chartWrapper: {
|
||||
marginBottom: 24,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.05)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
maxWidth: 320,
|
||||
listTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
emptyTitle: {
|
||||
listTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtitle: {
|
||||
listSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
glassAddButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackAddButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
emptySimpleContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptySimpleImage: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
opacity: 0.4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptySimpleText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptyActionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
footerContainer: {
|
||||
paddingVertical: 20,
|
||||
paddingVertical: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
opacity: 0.6,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
loadMoreButton: {
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadMoreText: {
|
||||
fontSize: 16,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
@@ -80,8 +82,9 @@ export default function EditProfileScreen() {
|
||||
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||
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}>
|
||||
|
||||
305
app/settings/tab-bar-config.tsx
Normal file
305
app/settings/tab-bar-config.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { useVipService } from '@/hooks/useVipService';
|
||||
import {
|
||||
resetToDefault,
|
||||
selectTabBarConfigs,
|
||||
toggleTabEnabled,
|
||||
type TabConfig,
|
||||
} from '@/store/tabBarConfigSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
|
||||
import { MembershipModal } from '@/components/model/MembershipModal';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
export default function TabBarConfigScreen() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const safeAreaTop = useSafeAreaTop(60);
|
||||
const configs = useAppSelector(selectTabBarConfigs);
|
||||
const { isVip } = useVipService();
|
||||
const [showMembershipModal, setShowMembershipModal] = useState(false);
|
||||
|
||||
// 处理开关切换
|
||||
const handleToggle = useCallback(
|
||||
(tabId: string) => {
|
||||
// 直接检查用户是否是 VIP(底部栏配置不是权益类功能,而是基础功能)
|
||||
if (isVip) {
|
||||
// VIP 用户可以正常切换
|
||||
dispatch(toggleTabEnabled(tabId));
|
||||
} else {
|
||||
// 非 VIP 用户显示购买弹窗
|
||||
setShowMembershipModal(true);
|
||||
}
|
||||
},
|
||||
[dispatch, isVip]
|
||||
);
|
||||
|
||||
// 页面加载时检查 VIP 状态
|
||||
useEffect(() => {
|
||||
if (!isVip) {
|
||||
// 非 VIP 用户进入页面时立即显示购买弹窗
|
||||
setShowMembershipModal(true);
|
||||
}
|
||||
}, [isVip]);
|
||||
|
||||
// 购买成功回调
|
||||
const handlePurchaseSuccess = useCallback(() => {
|
||||
// 购买成功后可以执行一些操作,比如刷新用户信息
|
||||
console.log('会员购买成功');
|
||||
}, []);
|
||||
|
||||
// 恢复默认设置
|
||||
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>
|
||||
)}
|
||||
{item.canBeDisabled && !isVip && (
|
||||
<Text style={styles.vipSubtitle}>
|
||||
{t('personal.tabBarConfig.vipOnly')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开关 */}
|
||||
<Switch
|
||||
value={item.enabled}
|
||||
onValueChange={() => handleToggle(item.id)}
|
||||
disabled={!item.canBeDisabled || !isVip}
|
||||
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>
|
||||
|
||||
{/* 会员购买弹窗 */}
|
||||
<MembershipModal
|
||||
visible={showMembershipModal}
|
||||
onClose={() => setShowMembershipModal(false)}
|
||||
onPurchaseSuccess={handlePurchaseSuccess}
|
||||
/>
|
||||
</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',
|
||||
},
|
||||
vipSubtitle: {
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
},
|
||||
switch: {
|
||||
transform: [{ scaleX: 0.9 }, { scaleY: 0.9 }],
|
||||
},
|
||||
headerRightButton: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#9370DB', // 使用主色调
|
||||
},
|
||||
});
|
||||
1155
app/sleep-detail.tsx
1155
app/sleep-detail.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { DateSelector } from '@/components/DateSelector';
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
export default function StepsDetailScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
// 获取路由参数
|
||||
@@ -169,11 +171,11 @@ export default function StepsDetailScreen() {
|
||||
|
||||
// 活动等级配置
|
||||
const activityLevels = useMemo(() => [
|
||||
{ key: 'inactive', label: '不怎么动', minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
|
||||
{ key: 'light', label: '轻度活跃', minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
|
||||
{ key: 'moderate', label: '中等活跃', minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
|
||||
{ key: 'very_active', label: '非常活跃', minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
|
||||
], []);
|
||||
{ key: 'inactive', label: t('stepsDetail.activityLevel.levels.inactive'), minSteps: 0, maxSteps: 3000, color: '#B8C8D6' },
|
||||
{ key: 'light', label: t('stepsDetail.activityLevel.levels.light'), minSteps: 3000, maxSteps: 7500, color: '#93C5FD' },
|
||||
{ key: 'moderate', label: t('stepsDetail.activityLevel.levels.moderate'), minSteps: 7500, maxSteps: 10000, color: '#FCD34D' },
|
||||
{ key: 'very_active', label: t('stepsDetail.activityLevel.levels.very_active'), minSteps: 10000, maxSteps: Infinity, color: '#FB923C' }
|
||||
], [t]);
|
||||
|
||||
// 计算当前活动等级
|
||||
const currentActivityLevel = useMemo(() => {
|
||||
@@ -211,7 +213,7 @@ export default function StepsDetailScreen() {
|
||||
/>
|
||||
|
||||
<HeaderBar
|
||||
title="步数详情"
|
||||
title={t('stepsDetail.title')}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
@@ -233,23 +235,23 @@ export default function StepsDetailScreen() {
|
||||
<View style={styles.statsCard}>
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<Text style={styles.loadingText}>加载中...</Text>
|
||||
<Text style={styles.loadingText}>{t('stepsDetail.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{totalSteps.toLocaleString()}</Text>
|
||||
<Text style={styles.statLabel}>总步数</Text>
|
||||
<Text style={styles.statLabel}>{t('stepsDetail.stats.totalSteps')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{averageHourlySteps}</Text>
|
||||
<Text style={styles.statLabel}>平均每小时</Text>
|
||||
<Text style={styles.statLabel}>{t('stepsDetail.stats.averagePerHour')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>
|
||||
{mostActiveHour ? `${mostActiveHour.hour}:00` : '--'}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>最活跃时段</Text>
|
||||
<Text style={styles.statLabel}>{t('stepsDetail.stats.mostActiveTime')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -258,7 +260,7 @@ export default function StepsDetailScreen() {
|
||||
{/* 详细柱状图卡片 */}
|
||||
<View style={styles.chartCard}>
|
||||
<View style={styles.chartHeader}>
|
||||
<Text style={styles.chartTitle}>每小时步数分布</Text>
|
||||
<Text style={styles.chartTitle}>{t('stepsDetail.chart.title')}</Text>
|
||||
<Text style={styles.chartSubtitle}>
|
||||
{dayjs(currentSelectedDate).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
@@ -290,7 +292,7 @@ export default function StepsDetailScreen() {
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.averageLineLabel}>
|
||||
平均 {averageHourlySteps}步
|
||||
{t('stepsDetail.chart.averageLabel', { steps: averageHourlySteps })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -354,9 +356,9 @@ export default function StepsDetailScreen() {
|
||||
|
||||
{/* 底部时间轴标签 */}
|
||||
<View style={styles.timeLabels}>
|
||||
<Text style={styles.timeLabel}>0:00</Text>
|
||||
<Text style={styles.timeLabel}>12:00</Text>
|
||||
<Text style={styles.timeLabel}>24:00</Text>
|
||||
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.midnight')}</Text>
|
||||
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.noon')}</Text>
|
||||
<Text style={styles.timeLabel}>{t('stepsDetail.timeLabels.nextDay')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -366,7 +368,7 @@ export default function StepsDetailScreen() {
|
||||
|
||||
|
||||
{/* 活动级别文本 */}
|
||||
<Text style={styles.activityMainText}>你今天的活动量处于</Text>
|
||||
<Text style={styles.activityMainText}>{t('stepsDetail.activityLevel.currentActivity')}</Text>
|
||||
<Text style={styles.activityLevelText}>{currentActivityLevel.label}</Text>
|
||||
|
||||
{/* 进度条 */}
|
||||
@@ -388,14 +390,14 @@ export default function StepsDetailScreen() {
|
||||
<View style={styles.stepsInfoContainer}>
|
||||
<View style={styles.currentStepsInfo}>
|
||||
<Text style={styles.stepsValue}>{totalSteps.toLocaleString()} 步</Text>
|
||||
<Text style={styles.stepsLabel}>当前</Text>
|
||||
<Text style={styles.stepsLabel}>{t('stepsDetail.activityLevel.progress.current')}</Text>
|
||||
</View>
|
||||
<View style={styles.nextStepsInfo}>
|
||||
<Text style={styles.stepsValue}>
|
||||
{nextActivityLevel ? `${nextActivityLevel.minSteps.toLocaleString()} 步` : '--'}
|
||||
</Text>
|
||||
<Text style={styles.stepsLabel}>
|
||||
{nextActivityLevel ? `下一级: ${nextActivityLevel.label}` : '已达最高级'}
|
||||
{nextActivityLevel ? t('stepsDetail.activityLevel.progress.nextLevel', { level: nextActivityLevel.label }) : t('stepsDetail.activityLevel.progress.highestLevel')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { analyzeFoodFromText } from '@/services/foodRecognition';
|
||||
import { saveRecognitionResult, setError, setLoading } from '@/store/foodRecognitionSlice';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type VoiceRecordState = 'idle' | 'listening' | 'processing' | 'result' | 'analyzing';
|
||||
|
||||
export default function VoiceRecordScreen() {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -118,7 +120,7 @@ export default function VoiceRecordScreen() {
|
||||
|
||||
// 语音识别回调 - 使用 useCallback 避免每次渲染重新创建
|
||||
const onSpeechStart = useCallback(() => {
|
||||
console.log('语音开始');
|
||||
console.log('Voice started');
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsListening(true);
|
||||
@@ -128,11 +130,11 @@ export default function VoiceRecordScreen() {
|
||||
}, []);
|
||||
|
||||
const onSpeechRecognized = useCallback(() => {
|
||||
console.log('语音识别中...');
|
||||
console.log('Voice recognition in progress...');
|
||||
}, []);
|
||||
|
||||
const onSpeechEnd = useCallback(() => {
|
||||
console.log('语音结束');
|
||||
console.log('Voice ended');
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsListening(false);
|
||||
@@ -141,7 +143,7 @@ export default function VoiceRecordScreen() {
|
||||
}, []);
|
||||
|
||||
const onSpeechError = useCallback((error: any) => {
|
||||
console.log('语音识别错误:', error);
|
||||
console.log('Voice recognition error:', error);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setIsListening(false);
|
||||
@@ -150,16 +152,16 @@ export default function VoiceRecordScreen() {
|
||||
|
||||
// 显示更友好的错误信息
|
||||
if (error.error?.code === '7') {
|
||||
Alert.alert('提示', '没有检测到语音输入,请重试');
|
||||
Alert.alert(t('voiceRecord.alerts.noVoiceInput'), t('voiceRecord.alerts.noVoiceInput'));
|
||||
} else if (error.error?.code === '2') {
|
||||
Alert.alert('提示', '网络连接异常,请检查网络后重试');
|
||||
Alert.alert(t('voiceRecord.alerts.networkError'), t('voiceRecord.alerts.networkError'));
|
||||
} else {
|
||||
Alert.alert('提示', '语音识别出现问题,请重试');
|
||||
Alert.alert(t('voiceRecord.alerts.voiceError'), t('voiceRecord.alerts.voiceError'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSpeechResults = useCallback((event: any) => {
|
||||
console.log('语音识别结果:', event);
|
||||
console.log('Voice recognition result:', event);
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const text = event.value?.[0] || '';
|
||||
@@ -168,7 +170,7 @@ export default function VoiceRecordScreen() {
|
||||
setRecordState('result');
|
||||
} else {
|
||||
setRecordState('idle');
|
||||
Alert.alert('提示', '未识别到有效内容,请重新录音');
|
||||
Alert.alert(t('voiceRecord.alerts.noValidContent'), t('voiceRecord.alerts.noValidContent'));
|
||||
}
|
||||
stopAnimations();
|
||||
}, []);
|
||||
@@ -215,7 +217,7 @@ export default function VoiceRecordScreen() {
|
||||
await Voice.destroy();
|
||||
Voice.removeAllListeners();
|
||||
} catch (error) {
|
||||
console.log('清理语音识别资源失败:', error);
|
||||
console.log('Failed to clean up voice recognition resources:', error);
|
||||
}
|
||||
};
|
||||
cleanup();
|
||||
@@ -246,22 +248,22 @@ export default function VoiceRecordScreen() {
|
||||
await Voice.start('zh-CN');
|
||||
|
||||
} catch (error) {
|
||||
console.log('启动语音识别失败:', error);
|
||||
console.log('Failed to start voice recognition:', error);
|
||||
setRecordState('idle');
|
||||
setIsListening(false);
|
||||
Alert.alert('录音失败', '无法启动语音识别,请检查麦克风权限设置');
|
||||
Alert.alert(t('voiceRecord.alerts.recordingFailed'), t('voiceRecord.alerts.recordingPermissionError'));
|
||||
}
|
||||
};
|
||||
|
||||
// 停止录音
|
||||
const stopRecording = async () => {
|
||||
try {
|
||||
console.log('停止录音');
|
||||
console.log('Stop recording');
|
||||
setIsListening(false);
|
||||
await Voice.stop();
|
||||
triggerHapticFeedback('impactLight');
|
||||
} catch (error) {
|
||||
console.log('停止语音识别失败:', error);
|
||||
console.log('Failed to stop voice recognition:', error);
|
||||
setIsListening(false);
|
||||
setRecordState('idle');
|
||||
}
|
||||
@@ -287,7 +289,7 @@ export default function VoiceRecordScreen() {
|
||||
startRecording();
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.log('重新录音失败:', error);
|
||||
console.log('Failed to retry recording:', error);
|
||||
setRecordState('idle');
|
||||
setIsListening(false);
|
||||
}
|
||||
@@ -296,7 +298,7 @@ export default function VoiceRecordScreen() {
|
||||
// 确认并分析食物文本
|
||||
const confirmResult = async () => {
|
||||
if (!recognizedText.trim()) {
|
||||
Alert.alert('提示', '请先进行语音识别');
|
||||
Alert.alert(t('voiceRecord.alerts.pleaseRecordFirst'), t('voiceRecord.alerts.pleaseRecordFirst'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,7 +384,7 @@ export default function VoiceRecordScreen() {
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '分析失败,请重试';
|
||||
dispatch(setError(errorMessage));
|
||||
Alert.alert('分析失败', errorMessage);
|
||||
Alert.alert(t('voiceRecord.alerts.analysisFailed'), errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -401,7 +403,7 @@ export default function VoiceRecordScreen() {
|
||||
|
||||
router.back();
|
||||
} catch (error) {
|
||||
console.log('返回时清理资源失败:', error);
|
||||
console.log('Failed to clean up resources when returning:', error);
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
@@ -410,15 +412,15 @@ export default function VoiceRecordScreen() {
|
||||
const getStatusText = () => {
|
||||
switch (recordState) {
|
||||
case 'idle':
|
||||
return '轻触麦克风开始录音';
|
||||
return t('voiceRecord.status.idle');
|
||||
case 'listening':
|
||||
return '正在聆听中,请开始说话...';
|
||||
return t('voiceRecord.status.listening');
|
||||
case 'processing':
|
||||
return 'AI正在处理语音内容...';
|
||||
return t('voiceRecord.status.processing');
|
||||
case 'analyzing':
|
||||
return 'AI大模型深度分析营养成分中...';
|
||||
return t('voiceRecord.status.analyzing');
|
||||
case 'result':
|
||||
return '语音识别完成,请确认结果';
|
||||
return t('voiceRecord.status.result');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -470,7 +472,7 @@ export default function VoiceRecordScreen() {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<HeaderBar
|
||||
title="一句话记录"
|
||||
title={t('voiceRecord.title')}
|
||||
onBack={handleBack}
|
||||
tone={theme}
|
||||
variant="elevated"
|
||||
@@ -485,7 +487,7 @@ export default function VoiceRecordScreen() {
|
||||
<View style={styles.topSection}>
|
||||
<View style={styles.introContainer}>
|
||||
<Text style={[styles.introDescription, { color: colorTokens.textSecondary }]}>
|
||||
通过语音描述您的饮食内容,AI将智能分析营养成分和卡路里
|
||||
{t('voiceRecord.intro.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -605,7 +607,7 @@ export default function VoiceRecordScreen() {
|
||||
|
||||
{recordState === 'listening' && (
|
||||
<Text style={[styles.hintText, { color: colorTokens.textSecondary }]}>
|
||||
说出您想记录的食物内容
|
||||
{t('voiceRecord.hints.listening')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -614,18 +616,18 @@ export default function VoiceRecordScreen() {
|
||||
<BlurView intensity={20} tint={theme} style={styles.examplesContainer}>
|
||||
<View style={styles.examplesContent}>
|
||||
<Text style={[styles.examplesTitle, { color: colorTokens.text }]}>
|
||||
记录示例:
|
||||
{t('voiceRecord.examples.title')}
|
||||
</Text>
|
||||
<View style={styles.examplesList}>
|
||||
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“今早吃了两个煎蛋、一片全麦面包和一杯牛奶”
|
||||
</Text>
|
||||
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“午饭吃了红烧肉约150克、米饭一小碗、青菜一份”
|
||||
</Text>
|
||||
<Text style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“晚饭吃了蒸蛋羹、紫菜蛋花汤、小米粥一碗”
|
||||
</Text>
|
||||
{[
|
||||
t('voiceRecord.examples.items.0'),
|
||||
t('voiceRecord.examples.items.1'),
|
||||
t('voiceRecord.examples.items.2')
|
||||
].map((example: string, index: number) => (
|
||||
<Text key={index} style={[styles.exampleText, { color: colorTokens.textSecondary }]}>
|
||||
“{example}”
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</BlurView>
|
||||
@@ -634,7 +636,7 @@ export default function VoiceRecordScreen() {
|
||||
{recordState === 'analyzing' && (
|
||||
<View style={styles.analysisProgressContainer}>
|
||||
<Text style={[styles.progressText, { color: colorTokens.text }]}>
|
||||
分析进度: {Math.round(analysisProgress)}%
|
||||
{t('voiceRecord.analysis.progress', { progress: Math.round(analysisProgress) })}
|
||||
</Text>
|
||||
<View style={styles.progressBarContainer}>
|
||||
<Animated.View
|
||||
@@ -650,7 +652,7 @@ export default function VoiceRecordScreen() {
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.analysisHint, { color: colorTokens.textSecondary }]}>
|
||||
AI正在深度分析您的食物描述...
|
||||
{t('voiceRecord.analysis.hint')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -662,7 +664,7 @@ export default function VoiceRecordScreen() {
|
||||
<BlurView intensity={20} tint={theme} style={styles.resultContainer}>
|
||||
<View style={styles.resultContent}>
|
||||
<Text style={[styles.resultLabel, { color: colorTokens.textSecondary }]}>
|
||||
识别结果:
|
||||
{t('voiceRecord.result.label')}
|
||||
</Text>
|
||||
<Text style={[styles.resultText, { color: colorTokens.text }]}>
|
||||
{recognizedText}
|
||||
@@ -675,7 +677,7 @@ export default function VoiceRecordScreen() {
|
||||
onPress={retryRecording}
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color="#7B68EE" />
|
||||
<Text style={styles.retryButtonText}>重新录音</Text>
|
||||
<Text style={styles.retryButtonText}>{t('voiceRecord.actions.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -683,7 +685,7 @@ export default function VoiceRecordScreen() {
|
||||
onPress={confirmResult}
|
||||
>
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
<Text style={styles.confirmButtonText}>确认使用</Text>
|
||||
<Text style={styles.confirmButtonText}>{t('voiceRecord.actions.confirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useWaterDataByDate } from '@/hooks/useWaterData';
|
||||
import { getQuickWaterAmount } from '@/utils/userPreferences';
|
||||
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';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { Swipeable } from 'react-native-gesture-handler';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -28,6 +30,7 @@ interface WaterDetailProps {
|
||||
}
|
||||
|
||||
const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
|
||||
const { selectedDate } = useLocalSearchParams<{ selectedDate?: string }>();
|
||||
@@ -37,22 +40,14 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
const [dailyGoal, setDailyGoal] = useState<string>('2000');
|
||||
const [quickAddAmount, setQuickAddAmount] = useState<string>('250');
|
||||
|
||||
// Remove modal states as they are now in separate settings page
|
||||
|
||||
// 使用新的 hook 来处理指定日期的饮水数据
|
||||
const { waterRecords, dailyWaterGoal, updateWaterGoal, removeWaterRecord } = useWaterDataByDate(selectedDate);
|
||||
|
||||
|
||||
|
||||
|
||||
// 处理设置按钮点击 - 跳转到设置页面
|
||||
const handleSettingsPress = () => {
|
||||
router.push('/water/settings');
|
||||
};
|
||||
|
||||
// Remove all modal-related functions as they are now in separate settings page
|
||||
|
||||
|
||||
// 删除饮水记录
|
||||
const handleDeleteRecord = async (recordId: string) => {
|
||||
await removeWaterRecord(recordId);
|
||||
@@ -70,13 +65,17 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
setDailyGoal(dailyWaterGoal.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户偏好设置失败:', error);
|
||||
console.error(t('waterDetail.loadingUserPreferences'), error);
|
||||
}
|
||||
};
|
||||
|
||||
loadUserPreferences();
|
||||
}, [dailyWaterGoal]);
|
||||
|
||||
const totalAmount = waterRecords?.reduce((sum, record) => sum + record.amount, 0) || 0;
|
||||
const currentGoal = dailyWaterGoal || 2000;
|
||||
const progress = Math.min(100, (totalAmount / currentGoal) * 100);
|
||||
|
||||
// 新增:饮水记录卡片组件
|
||||
const WaterRecordCard = ({ record, onDelete }: { record: any; onDelete: () => void }) => {
|
||||
const swipeableRef = React.useRef<Swipeable>(null);
|
||||
@@ -84,15 +83,15 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
// 处理删除操作
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条饮水记录吗?此操作无法撤销。',
|
||||
t('waterDetail.deleteConfirm.title'),
|
||||
t('waterDetail.deleteConfirm.message'),
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
text: t('waterDetail.deleteConfirm.cancel'),
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
text: t('waterDetail.deleteConfirm.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete();
|
||||
@@ -112,7 +111,6 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.deleteSwipeButtonText}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -125,29 +123,29 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
rightThreshold={40}
|
||||
overshootRight={false}
|
||||
>
|
||||
<View style={[styles.recordCard, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<View style={styles.recordCard}>
|
||||
<View style={styles.recordMainContent}>
|
||||
<View style={[styles.recordIconContainer, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={styles.recordIconContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/IconGlass.png')}
|
||||
style={styles.recordIcon}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.recordInfo}>
|
||||
<Text style={[styles.recordLabel, { color: colorTokens.text }]}>水</Text>
|
||||
<Text style={styles.recordLabel}>{t('waterDetail.water')}</Text>
|
||||
<View style={styles.recordTimeContainer}>
|
||||
<Ionicons name="time-outline" size={14} color={colorTokens.textSecondary} />
|
||||
<Text style={[styles.recordTimeText, { color: colorTokens.textSecondary }]}>
|
||||
<Ionicons name="time-outline" size={14} color="#6f7ba7" />
|
||||
<Text style={styles.recordTimeText}>
|
||||
{dayjs(record.recordedAt || record.createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.recordAmountContainer}>
|
||||
<Text style={[styles.recordAmount, { color: colorTokens.text }]}>{record.amount}ml</Text>
|
||||
<Text style={styles.recordAmount}>{record.amount}ml</Text>
|
||||
</View>
|
||||
</View>
|
||||
{record.note && (
|
||||
<Text style={[styles.recordNote, { color: colorTokens.textSecondary }]}>{record.note}</Text>
|
||||
<Text style={styles.recordNote}>{record.note}</Text>
|
||||
)}
|
||||
</View>
|
||||
</Swipeable>
|
||||
@@ -157,32 +155,47 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 背景渐变 */}
|
||||
{/* 背景 */}
|
||||
<LinearGradient
|
||||
colors={['#f5e5fbff', '#e5fcfeff', '#eefdffff', '#e6f6fcff']}
|
||||
style={styles.gradientBackground}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
colors={['#f3f4fb', '#f3f4fb']}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
{/* 顶部装饰性渐变 - 模仿挑战页面的柔和背景感 */}
|
||||
<LinearGradient
|
||||
colors={['rgba(229, 252, 254, 0.8)', 'rgba(243, 244, 251, 0)']}
|
||||
style={styles.topGradient}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
{/* 装饰性圆圈 */}
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<HeaderBar
|
||||
title="饮水详情"
|
||||
onBack={() => {
|
||||
// 这里会通过路由自动处理返回
|
||||
router.back();
|
||||
}}
|
||||
title={t('waterDetail.title')}
|
||||
onBack={() => router.back()}
|
||||
right={
|
||||
<TouchableOpacity
|
||||
style={styles.settingsButton}
|
||||
onPress={handleSettingsPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={24} color={colorTokens.text} />
|
||||
</TouchableOpacity>
|
||||
isLiquidGlassAvailable() ? (
|
||||
<TouchableOpacity
|
||||
onPress={handleSettingsPress}
|
||||
activeOpacity={0.7}
|
||||
style={styles.settingsButtonWrapper}
|
||||
>
|
||||
<GlassView
|
||||
style={styles.settingsButtonGlass}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.4)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
|
||||
</GlassView>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.settingsButtonFallback}
|
||||
onPress={handleSettingsPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="settings-outline" size={22} color="#1c1f3a" />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -197,13 +210,37 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
}]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
|
||||
{/* 第二部分:饮水记录 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colorTokens.text }]}>
|
||||
{selectedDate ? dayjs(selectedDate).format('MM月DD日') : '今日'}饮水记录
|
||||
<View style={styles.headerBlock}>
|
||||
<Text style={styles.pageTitle}>
|
||||
{selectedDate ? dayjs(selectedDate).format('MM-DD') : t('waterDetail.today')}
|
||||
</Text>
|
||||
<Text style={styles.pageSubtitle}>{t('waterDetail.waterRecord')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 进度卡片 */}
|
||||
<View style={styles.progressCard}>
|
||||
<View style={styles.progressInfo}>
|
||||
<View>
|
||||
<Text style={styles.progressLabel}>{t('waterDetail.total')}</Text>
|
||||
<Text style={styles.progressValue}>{totalAmount}<Text style={styles.progressUnit}>ml</Text></Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Text style={styles.progressLabel}>{t('waterDetail.goal')}</Text>
|
||||
<Text style={styles.progressGoalValue}>{currentGoal}<Text style={styles.progressUnit}>ml</Text></Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.progressBarBg}>
|
||||
<LinearGradient
|
||||
colors={['#4F5BD5', '#6B6CFF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[styles.progressBarFill, { width: `${progress}%` }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 记录列表 */}
|
||||
<View style={styles.section}>
|
||||
{waterRecords && waterRecords.length > 0 ? (
|
||||
<View style={styles.recordsList}>
|
||||
{waterRecords.map((record) => (
|
||||
@@ -213,29 +250,20 @@ const WaterDetail: React.FC<WaterDetailProps> = () => {
|
||||
onDelete={() => handleDeleteRecord(record.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 总计显示 */}
|
||||
<View style={[styles.recordsSummary, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}>
|
||||
<Text style={[styles.summaryText, { color: colorTokens.text }]}>
|
||||
总计:{waterRecords.reduce((sum, record) => sum + record.amount, 0)}ml
|
||||
</Text>
|
||||
<Text style={[styles.summaryGoal, { color: colorTokens.textSecondary }]}>
|
||||
目标:{dailyWaterGoal}ml
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.noRecordsContainer}>
|
||||
<Ionicons name="water-outline" size={48} color={colorTokens.textSecondary} />
|
||||
<Text style={[styles.noRecordsText, { color: colorTokens.textSecondary }]}>暂无饮水记录</Text>
|
||||
<Text style={[styles.noRecordsSubText, { color: colorTokens.textSecondary }]}>点击"添加记录"开始记录饮水量</Text>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/IconGlass.png')}
|
||||
style={{ width: 60, height: 60, opacity: 0.5, marginBottom: 16 }}
|
||||
/>
|
||||
<Text style={styles.noRecordsText}>{t('waterDetail.noRecords')}</Text>
|
||||
<Text style={styles.noRecordsSubText}>{t('waterDetail.noRecordsSubtitle')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* All modals have been moved to the separate water-settings page */}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -245,32 +273,12 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#f3f4fb',
|
||||
},
|
||||
gradientBackground: {
|
||||
topGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
top: 80,
|
||||
right: 30,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#4F5BD5',
|
||||
opacity: 0.08,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: -20,
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
backgroundColor: '#4F5BD5',
|
||||
opacity: 0.06,
|
||||
height: 300,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
@@ -279,54 +287,107 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
headerBlock: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
marginTop: 10,
|
||||
marginBottom: 24,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 36,
|
||||
pageTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
pageSubtitle: {
|
||||
fontSize: 16,
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
|
||||
// 进度卡片
|
||||
progressCard: {
|
||||
marginHorizontal: 24,
|
||||
marginBottom: 32,
|
||||
padding: 24,
|
||||
borderRadius: 28,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.1)',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
progressInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: 16,
|
||||
},
|
||||
progressLabel: {
|
||||
fontSize: 14,
|
||||
color: '#6f7ba7',
|
||||
marginBottom: 6,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
progressValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
color: '#4F5BD5',
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
progressGoalValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 24,
|
||||
letterSpacing: -0.5,
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subsectionTitle: {
|
||||
progressUnit: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
letterSpacing: -0.3,
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
color: '#6f7ba7',
|
||||
marginLeft: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
// 饮水记录相关样式
|
||||
progressBarBg: {
|
||||
height: 12,
|
||||
backgroundColor: '#F0F2F5',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 6,
|
||||
},
|
||||
|
||||
section: {
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
|
||||
// 记录列表样式
|
||||
recordsList: {
|
||||
gap: 16,
|
||||
},
|
||||
recordCardContainer: {
|
||||
// iOS 阴影效果 - 增强阴影效果
|
||||
shadowColor: 'rgba(30, 41, 59, 0.18)',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 16,
|
||||
// Android 阴影效果
|
||||
elevation: 6,
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
marginBottom: 2,
|
||||
},
|
||||
recordCard: {
|
||||
borderRadius: 20,
|
||||
borderRadius: 24,
|
||||
padding: 18,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
recordMainContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
recordIconContainer: {
|
||||
width: 48,
|
||||
@@ -334,7 +395,7 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.08)',
|
||||
backgroundColor: '#f5f6ff',
|
||||
},
|
||||
recordIcon: {
|
||||
width: 24,
|
||||
@@ -345,15 +406,21 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 16,
|
||||
},
|
||||
recordLabel: {
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
marginBottom: 6,
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
recordTimeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
gap: 4,
|
||||
},
|
||||
recordTimeText: {
|
||||
fontSize: 13,
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
recordAmountContainer: {
|
||||
alignItems: 'flex-end',
|
||||
@@ -362,364 +429,74 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#4F5BD5',
|
||||
},
|
||||
deleteSwipeButton: {
|
||||
backgroundColor: '#EF4444',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
borderRadius: 16,
|
||||
marginLeft: 12,
|
||||
},
|
||||
deleteSwipeButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
},
|
||||
recordTimeText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#6f7ba7',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
recordNote: {
|
||||
marginTop: 12,
|
||||
marginTop: 14,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(79, 91, 213, 0.04)',
|
||||
backgroundColor: '#F8F9FC',
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
fontStyle: 'normal',
|
||||
lineHeight: 20,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: '#5f6a97',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
recordsSummary: {
|
||||
marginTop: 24,
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.12)',
|
||||
shadowOpacity: 0.16,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
elevation: 6,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
deleteSwipeButton: {
|
||||
backgroundColor: '#FF6B6B',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 70,
|
||||
height: '100%',
|
||||
borderRadius: 24,
|
||||
marginLeft: 12,
|
||||
},
|
||||
summaryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1c1f3a',
|
||||
},
|
||||
summaryGoal: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6f7ba7',
|
||||
},
|
||||
|
||||
noRecordsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
gap: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 28,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.06)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
noRecordsText: {
|
||||
fontSize: 17,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
lineHeight: 24,
|
||||
color: '#6f7ba7',
|
||||
color: '#1c1f3a',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
noRecordsSubText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
color: '#9ba3c7',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
modalBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
|
||||
// Settings Button
|
||||
settingsButtonWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
// iOS 阴影效果
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
// Android 阴影效果
|
||||
elevation: 16,
|
||||
},
|
||||
modalHandle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
backgroundColor: '#E0E0E0',
|
||||
borderRadius: 2,
|
||||
alignSelf: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
pickerContainer: {
|
||||
height: 200,
|
||||
marginBottom: 20,
|
||||
},
|
||||
picker: {
|
||||
height: 200,
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
},
|
||||
modalBtn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
minWidth: 80,
|
||||
settingsButtonGlass: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalBtnPrimary: {
|
||||
// backgroundColor will be set dynamically
|
||||
},
|
||||
modalBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalBtnTextPrimary: {
|
||||
// color will be set dynamically
|
||||
},
|
||||
settingsButton: {
|
||||
settingsButtonFallback: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.24)',
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.45)',
|
||||
},
|
||||
settingsModalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
settingsModalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
settingsMenuContainer: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingsMenuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F1F3F4',
|
||||
},
|
||||
settingsMenuItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
settingsIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 6,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
settingsMenuItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
settingsMenuItemTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
settingsMenuItemSubtitle: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingsMenuItemValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
// 喝水提醒配置弹窗样式
|
||||
waterReminderModalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: '80%',
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
waterReminderContent: {
|
||||
flex: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
waterReminderSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
waterReminderSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
waterReminderSectionTitleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
waterReminderSectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
waterReminderSectionDesc: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginTop: 4,
|
||||
},
|
||||
timeRangeContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
timePickerContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
timeLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timePicker: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
timePickerText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
timePickerIcon: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
intervalContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
intervalPickerContainer: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
intervalPicker: {
|
||||
height: 120,
|
||||
},
|
||||
// 时间选择器弹窗样式
|
||||
timePickerModalSheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
maxHeight: '60%',
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
timePickerContent: {
|
||||
flex: 1,
|
||||
marginBottom: 20,
|
||||
},
|
||||
timePickerSection: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
timePickerLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
hourPickerContainer: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
hourPicker: {
|
||||
height: 160,
|
||||
},
|
||||
timeRangePreview: {
|
||||
backgroundColor: '#F0F8FF',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
timeRangePreviewLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
timeRangePreviewText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
timeRangeWarning: {
|
||||
fontSize: 12,
|
||||
color: '#FF6B6B',
|
||||
textAlign: 'center',
|
||||
lineHeight: 18,
|
||||
borderColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
const WaterReminderSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -71,9 +73,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
setStartTimePickerVisible(false);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'时间设置提示',
|
||||
'开始时间不能晚于或等于结束时间,请重新选择',
|
||||
[{ text: '确定' }]
|
||||
t('waterReminderSettings.alerts.timeValidation.title'),
|
||||
t('waterReminderSettings.alerts.timeValidation.startTimeInvalid'),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm') }]
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -91,9 +93,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
setEndTimePickerVisible(false);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'时间设置提示',
|
||||
'结束时间不能早于或等于开始时间,请重新选择',
|
||||
[{ text: '确定' }]
|
||||
t('waterReminderSettings.alerts.timeValidation.title'),
|
||||
t('waterReminderSettings.alerts.timeValidation.endTimeInvalid'),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm') }]
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -125,18 +127,28 @@ const WaterReminderSettings: React.FC = () => {
|
||||
|
||||
if (waterReminderSettings.enabled) {
|
||||
const timeInfo = `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}`;
|
||||
const intervalInfo = `每${waterReminderSettings.interval}分钟`;
|
||||
const intervalInfo = `${waterReminderSettings.interval}${t('waterReminderSettings.labels.minutes')}`;
|
||||
Alert.alert(
|
||||
'设置成功',
|
||||
`喝水提醒已开启\n\n时间段:${timeInfo}\n提醒间隔:${intervalInfo}\n\n我们将在指定时间段内定期提醒您喝水`,
|
||||
[{ text: '确定', onPress: () => router.back() }]
|
||||
t('waterReminderSettings.alerts.success.enabled'),
|
||||
t('waterReminderSettings.alerts.success.enabledMessage', {
|
||||
timeRange: timeInfo,
|
||||
interval: intervalInfo
|
||||
}),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
|
||||
);
|
||||
} else {
|
||||
Alert.alert('设置成功', '喝水提醒已关闭', [{ text: '确定', onPress: () => router.back() }]);
|
||||
Alert.alert(
|
||||
t('waterReminderSettings.alerts.success.disabled'),
|
||||
t('waterReminderSettings.alerts.success.disabledMessage'),
|
||||
[{ text: t('waterReminderSettings.buttons.confirm'), onPress: () => router.back() }]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存喝水提醒设置失败:', error);
|
||||
Alert.alert('保存失败', '无法保存喝水提醒设置,请重试');
|
||||
Alert.alert(
|
||||
t('waterReminderSettings.alerts.error.title'),
|
||||
t('waterReminderSettings.alerts.error.message')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,7 +188,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<HeaderBar
|
||||
title="喝水提醒"
|
||||
title={t('waterReminderSettings.title')}
|
||||
onBack={() => {
|
||||
router.back();
|
||||
}}
|
||||
@@ -198,7 +210,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<View style={styles.waterReminderSectionHeader}>
|
||||
<View style={styles.waterReminderSectionTitleContainer}>
|
||||
<Ionicons name="notifications-outline" size={20} color={colorTokens.text} />
|
||||
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>推送提醒</Text>
|
||||
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.notifications')}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={waterReminderSettings.enabled}
|
||||
@@ -208,7 +220,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||
开启后将在指定时间段内定期推送喝水提醒
|
||||
{t('waterReminderSettings.descriptions.notifications')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -216,15 +228,15 @@ const WaterReminderSettings: React.FC = () => {
|
||||
{waterReminderSettings.enabled && (
|
||||
<>
|
||||
<View style={styles.waterReminderSection}>
|
||||
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>提醒时间段</Text>
|
||||
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.timeRange')}</Text>
|
||||
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||
只在指定时间段内发送提醒,避免打扰您的休息
|
||||
{t('waterReminderSettings.descriptions.timeRange')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.timeRangeContainer}>
|
||||
{/* 开始时间 */}
|
||||
<View style={styles.timePickerContainer}>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>开始时间</Text>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
|
||||
<Pressable
|
||||
style={[styles.timePicker, { backgroundColor: 'white' }]}
|
||||
onPress={openStartTimePicker}
|
||||
@@ -236,7 +248,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
|
||||
{/* 结束时间 */}
|
||||
<View style={styles.timePickerContainer}>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>结束时间</Text>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
|
||||
<Pressable
|
||||
style={[styles.timePicker, { backgroundColor: 'white' }]}
|
||||
onPress={openEndTimePicker}
|
||||
@@ -250,9 +262,9 @@ const WaterReminderSettings: React.FC = () => {
|
||||
|
||||
{/* 提醒间隔设置 */}
|
||||
<View style={styles.waterReminderSection}>
|
||||
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>提醒间隔</Text>
|
||||
<Text style={[styles.waterReminderSectionTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.sections.interval')}</Text>
|
||||
<Text style={[styles.waterReminderSectionDesc, { color: colorTokens.textSecondary }]}>
|
||||
选择提醒的频率,建议30-120分钟为宜
|
||||
{t('waterReminderSettings.descriptions.interval')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.intervalContainer}>
|
||||
@@ -263,7 +275,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
style={styles.intervalPicker}
|
||||
>
|
||||
{[30, 45, 60, 90, 120, 150, 180].map(interval => (
|
||||
<Picker.Item key={interval} label={`${interval}分钟`} value={interval} />
|
||||
<Picker.Item key={interval} label={`${interval}${t('waterReminderSettings.labels.minutes')}`} value={interval} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
@@ -279,7 +291,7 @@ const WaterReminderSettings: React.FC = () => {
|
||||
onPress={handleWaterReminderSave}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>保存设置</Text>
|
||||
<Text style={[styles.saveButtonText, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.labels.saveSettings')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -295,11 +307,11 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setStartTimePickerVisible(false)} />
|
||||
<View style={styles.timePickerModalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择开始时间</Text>
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.startTime')}</Text>
|
||||
|
||||
<View style={styles.timePickerContent}>
|
||||
<View style={styles.timePickerSection}>
|
||||
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>小时</Text>
|
||||
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
|
||||
<View style={styles.hourPickerContainer}>
|
||||
<Picker
|
||||
selectedValue={tempStartHour}
|
||||
@@ -314,12 +326,12 @@ const WaterReminderSettings: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<View style={styles.timeRangePreview}>
|
||||
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>时间段预览</Text>
|
||||
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
|
||||
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
|
||||
{String(tempStartHour).padStart(2, '0')}:00 - {waterReminderSettings.endTime}
|
||||
</Text>
|
||||
{!isValidTimeRange(`${String(tempStartHour).padStart(2, '0')}:00`, waterReminderSettings.endTime) && (
|
||||
<Text style={styles.timeRangeWarning}>⚠️ 开始时间不能晚于或等于结束时间</Text>
|
||||
<Text style={styles.timeRangeWarning}>⚠️ {t('waterReminderSettings.alerts.timeValidation.startTimeInvalid')}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -329,13 +341,13 @@ const WaterReminderSettings: React.FC = () => {
|
||||
onPress={() => setStartTimePickerVisible(false)}
|
||||
style={[styles.modalBtn, { backgroundColor: 'white' }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={confirmStartTime}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -351,11 +363,11 @@ const WaterReminderSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setEndTimePickerVisible(false)} />
|
||||
<View style={styles.timePickerModalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>选择结束时间</Text>
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.endTime')}</Text>
|
||||
|
||||
<View style={styles.timePickerContent}>
|
||||
<View style={styles.timePickerSection}>
|
||||
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>小时</Text>
|
||||
<Text style={[styles.timePickerLabel, { color: colorTokens.text }]}>{t('waterReminderSettings.labels.hours')}</Text>
|
||||
<View style={styles.hourPickerContainer}>
|
||||
<Picker
|
||||
selectedValue={tempEndHour}
|
||||
@@ -370,12 +382,12 @@ const WaterReminderSettings: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<View style={styles.timeRangePreview}>
|
||||
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>时间段预览</Text>
|
||||
<Text style={[styles.timeRangePreviewLabel, { color: colorTokens.textSecondary }]}>{t('waterReminderSettings.labels.timeRangePreview')}</Text>
|
||||
<Text style={[styles.timeRangePreviewText, { color: colorTokens.text }]}>
|
||||
{waterReminderSettings.startTime} - {String(tempEndHour).padStart(2, '0')}:00
|
||||
</Text>
|
||||
{!isValidTimeRange(waterReminderSettings.startTime, `${String(tempEndHour).padStart(2, '0')}:00`) && (
|
||||
<Text style={styles.timeRangeWarning}>⚠️ 结束时间不能早于或等于开始时间</Text>
|
||||
<Text style={styles.timeRangeWarning}>⚠️ {t('waterReminderSettings.alerts.timeValidation.endTimeInvalid')}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -385,13 +397,13 @@ const WaterReminderSettings: React.FC = () => {
|
||||
onPress={() => setEndTimePickerVisible(false)}
|
||||
style={[styles.modalBtn, { backgroundColor: 'white' }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterReminderSettings.buttons.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={confirmEndTime}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterReminderSettings.buttons.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
} from 'react-native';
|
||||
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
|
||||
const WaterSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
@@ -74,7 +76,10 @@ const WaterSettings: React.FC = () => {
|
||||
setGoalModalVisible(false);
|
||||
|
||||
// 这里可以添加保存到本地存储或发送到后端的逻辑
|
||||
Alert.alert('设置成功', `每日饮水目标已设置为 ${tempGoal}ml`);
|
||||
Alert.alert(
|
||||
t('waterSettings.alerts.goalSuccess.title'),
|
||||
t('waterSettings.alerts.goalSuccess.message', { amount: tempGoal })
|
||||
);
|
||||
};
|
||||
|
||||
// 处理快速添加默认值确认
|
||||
@@ -84,9 +89,15 @@ const WaterSettings: React.FC = () => {
|
||||
|
||||
try {
|
||||
await setQuickWaterAmount(tempQuickAdd);
|
||||
Alert.alert('设置成功', `快速添加默认值已设置为 ${tempQuickAdd}ml`);
|
||||
Alert.alert(
|
||||
t('waterSettings.alerts.quickAddSuccess.title'),
|
||||
t('waterSettings.alerts.quickAddSuccess.message', { amount: tempQuickAdd })
|
||||
);
|
||||
} catch {
|
||||
Alert.alert('设置失败', '无法保存快速添加默认值,请重试');
|
||||
Alert.alert(
|
||||
t('waterSettings.alerts.quickAddFailed.title'),
|
||||
t('waterSettings.alerts.quickAddFailed.message')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,7 +112,7 @@ const WaterSettings: React.FC = () => {
|
||||
const reminderSettings = await getWaterReminderSettings();
|
||||
setWaterReminderSettings(reminderSettings);
|
||||
} catch (error) {
|
||||
console.error('加载用户偏好设置失败:', error);
|
||||
console.error('Failed to load user preferences:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -132,7 +143,7 @@ const WaterSettings: React.FC = () => {
|
||||
<View style={styles.decorativeCircle2} />
|
||||
|
||||
<HeaderBar
|
||||
title="饮水设置"
|
||||
title={t('waterSettings.title')}
|
||||
onBack={() => {
|
||||
router.back();
|
||||
}}
|
||||
@@ -156,8 +167,8 @@ const WaterSettings: React.FC = () => {
|
||||
<Ionicons name="flag-outline" size={20} color="#9370DB" />
|
||||
</View>
|
||||
<View style={styles.settingsMenuItemContent}>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}ml</Text>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{currentWaterGoal}{t('waterSettings.labels.ml')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
@@ -169,11 +180,11 @@ const WaterSettings: React.FC = () => {
|
||||
<Ionicons name="add-outline" size={20} color="#9370DB" />
|
||||
</View>
|
||||
<View style={styles.settingsMenuItemContent}>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
|
||||
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
设置点击"+"按钮时添加的默认饮水量
|
||||
{t('waterSettings.descriptions.quickAdd')}
|
||||
</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}ml</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>{quickAddAmount}{t('waterSettings.labels.ml')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#CCCCCC" />
|
||||
@@ -185,12 +196,19 @@ const WaterSettings: React.FC = () => {
|
||||
<Ionicons name="notifications-outline" size={20} color="#3498DB" />
|
||||
</View>
|
||||
<View style={styles.settingsMenuItemContent}>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>喝水提醒</Text>
|
||||
<Text style={[styles.settingsMenuItemTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.reminder')}</Text>
|
||||
<Text style={[styles.settingsMenuItemSubtitle, { color: colorTokens.textSecondary }]}>
|
||||
设置定时提醒您补充水分
|
||||
{t('waterSettings.descriptions.reminder')}
|
||||
</Text>
|
||||
<Text style={[styles.settingsMenuItemValue, { color: colorTokens.textSecondary }]}>
|
||||
{waterReminderSettings.enabled ? `${waterReminderSettings.startTime}-${waterReminderSettings.endTime}, 每${waterReminderSettings.interval}分钟` : '已关闭'}
|
||||
{waterReminderSettings.enabled
|
||||
? t('waterSettings.status.reminderEnabled', {
|
||||
startTime: waterReminderSettings.startTime,
|
||||
endTime: waterReminderSettings.endTime,
|
||||
interval: waterReminderSettings.interval
|
||||
})
|
||||
: t('waterSettings.labels.disabled')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -211,7 +229,7 @@ const WaterSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setGoalModalVisible(false)} />
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>每日饮水目标</Text>
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.dailyGoal')}</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={tempGoal}
|
||||
@@ -219,7 +237,7 @@ const WaterSettings: React.FC = () => {
|
||||
style={styles.picker}
|
||||
>
|
||||
{Array.from({ length: 96 }, (_, i) => 500 + i * 100).map(goal => (
|
||||
<Picker.Item key={goal} label={`${goal}ml`} value={goal} />
|
||||
<Picker.Item key={goal} label={`${goal}${t('waterSettings.labels.ml')}`} value={goal} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
@@ -228,13 +246,13 @@ const WaterSettings: React.FC = () => {
|
||||
onPress={() => setGoalModalVisible(false)}
|
||||
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleGoalConfirm}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -250,7 +268,7 @@ const WaterSettings: React.FC = () => {
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setQuickAddModalVisible(false)} />
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>快速添加默认值</Text>
|
||||
<Text style={[styles.modalTitle, { color: colorTokens.text }]}>{t('waterSettings.sections.quickAdd')}</Text>
|
||||
<View style={styles.pickerContainer}>
|
||||
<Picker
|
||||
selectedValue={tempQuickAdd}
|
||||
@@ -258,7 +276,7 @@ const WaterSettings: React.FC = () => {
|
||||
style={styles.picker}
|
||||
>
|
||||
{Array.from({ length: 41 }, (_, i) => 50 + i * 10).map(amount => (
|
||||
<Picker.Item key={amount} label={`${amount}ml`} value={amount} />
|
||||
<Picker.Item key={amount} label={`${amount}${t('waterSettings.labels.ml')}`} value={amount} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
@@ -267,13 +285,13 @@ const WaterSettings: React.FC = () => {
|
||||
onPress={() => setQuickAddModalVisible(false)}
|
||||
style={[styles.modalBtn, { backgroundColor: colorTokens.pageBackgroundEmphasis }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>取消</Text>
|
||||
<Text style={[styles.modalBtnText, { color: colorTokens.text }]}>{t('waterSettings.buttons.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleQuickAddConfirm}
|
||||
style={[styles.modalBtn, styles.modalBtnPrimary, { backgroundColor: colorTokens.primary }]}
|
||||
>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary, { color: colorTokens.onPrimary }]}>{t('waterSettings.buttons.confirm')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||
import { IntensityBadge, WorkoutDetailModal } from '@/components/workout/WorkoutDetailModal';
|
||||
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
|
||||
import { getWorkoutDetailMetrics, WorkoutDetailMetrics } from '@/services/workoutDetail';
|
||||
import {
|
||||
@@ -233,23 +235,23 @@ function computeMonthlyStats(workouts: WorkoutData[]): MonthlyStatsInfo | null {
|
||||
};
|
||||
}
|
||||
|
||||
function getIntensityBadge(totalCalories?: number, durationInSeconds?: number) {
|
||||
function getIntensityBadge(t: (key: string, options?: any) => string, totalCalories?: number, durationInSeconds?: number): { label: string; color: string; background: string } {
|
||||
if (!totalCalories || !durationInSeconds) {
|
||||
return { label: '低强度', color: '#7C85A3', background: '#E4E7F2' };
|
||||
return { label: t('workoutHistory.intensity.low'), color: '#7C85A3', background: '#E4E7F2' };
|
||||
}
|
||||
|
||||
const minutes = Math.max(durationInSeconds / 60, 1);
|
||||
const caloriesPerMinute = totalCalories / minutes;
|
||||
|
||||
if (caloriesPerMinute >= 9) {
|
||||
return { label: '高强度', color: '#F85959', background: '#FFE6E6' };
|
||||
return { label: t('workoutHistory.intensity.high'), color: '#F85959', background: '#FFE6E6' };
|
||||
}
|
||||
|
||||
if (caloriesPerMinute >= 5) {
|
||||
return { label: '中强度', color: '#0EAF71', background: '#E4F6EF' };
|
||||
return { label: t('workoutHistory.intensity.medium'), color: '#0EAF71', background: '#E4F6EF' };
|
||||
}
|
||||
|
||||
return { label: '低强度', color: '#5966FF', background: '#E7EBFF' };
|
||||
return { label: t('workoutHistory.intensity.low'), color: '#5966FF', background: '#E7EBFF' };
|
||||
}
|
||||
|
||||
function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
||||
@@ -265,13 +267,15 @@ function groupWorkouts(workouts: WorkoutData[]): WorkoutSection[] {
|
||||
return Object.keys(grouped)
|
||||
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())
|
||||
.map((dateKey) => ({
|
||||
title: dayjs(dateKey).format('M月D日'),
|
||||
title: dayjs(dateKey).format('M月D日'), // 保持中文格式,因为这是日期格式
|
||||
data: grouped[dateKey]
|
||||
.sort((a, b) => dayjs(b.startDate || b.endDate).valueOf() - dayjs(a.startDate || a.endDate).valueOf()),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function WorkoutHistoryScreen() {
|
||||
const { t } = useI18n();
|
||||
const { workoutId: workoutIdParam } = useLocalSearchParams<{ workoutId?: string | string[] }>();
|
||||
const [sections, setSections] = useState<WorkoutSection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -283,8 +287,19 @@ export default function WorkoutHistoryScreen() {
|
||||
const [selectedIntensity, setSelectedIntensity] = useState<IntensityBadge | null>(null);
|
||||
const [monthOccurrenceText, setMonthOccurrenceText] = useState<string | null>(null);
|
||||
const [monthlyStats, setMonthlyStats] = useState<MonthlyStatsInfo | null>(null);
|
||||
const [pendingWorkoutId, setPendingWorkoutId] = useState<string | null>(null);
|
||||
|
||||
const safeAreaTop = useSafeAreaTop()
|
||||
const safeAreaTop = useSafeAreaTop();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!workoutIdParam) {
|
||||
return;
|
||||
}
|
||||
const idParam = Array.isArray(workoutIdParam) ? workoutIdParam[0] : workoutIdParam;
|
||||
if (idParam) {
|
||||
setPendingWorkoutId(idParam);
|
||||
}
|
||||
}, [workoutIdParam]);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -302,7 +317,7 @@ export default function WorkoutHistoryScreen() {
|
||||
|
||||
if (!hasPermission) {
|
||||
setSections([]);
|
||||
setError('尚未授予健康数据权限');
|
||||
setError(t('workoutHistory.error.permissionDenied'));
|
||||
setMonthlyStats(null);
|
||||
return;
|
||||
}
|
||||
@@ -315,8 +330,8 @@ export default function WorkoutHistoryScreen() {
|
||||
setMonthlyStats(computeMonthlyStats(filteredWorkouts));
|
||||
setSections(groupWorkouts(filteredWorkouts));
|
||||
} catch (err) {
|
||||
console.error('加载锻炼历史失败:', err);
|
||||
setError('加载锻炼记录失败,请稍后再试');
|
||||
console.error('Failed to load workout history:', err);
|
||||
setError(t('workoutHistory.error.loadFailed'));
|
||||
setSections([]);
|
||||
setMonthlyStats(null);
|
||||
} finally {
|
||||
@@ -350,9 +365,9 @@ export default function WorkoutHistoryScreen() {
|
||||
? dayjs(monthlyStats.snapshotDate).format('M月D日')
|
||||
: dayjs().format('M月D日');
|
||||
const overviewText = monthlyStats
|
||||
? `截至${snapshotLabel},你已完成${monthlyStats.totalCount}次锻炼,累计${formatDurationShort(monthlyStats.totalDuration)}。`
|
||||
: '本月还没有锻炼记录,动起来收集第一条吧!';
|
||||
const periodText = `统计周期:1日 - ${monthEndDay}日(本月)`;
|
||||
? t('workoutHistory.monthlyStats.overviewWithStats', { date: snapshotLabel, count: monthlyStats.totalCount, duration: formatDurationShort(monthlyStats.totalDuration) })
|
||||
: t('workoutHistory.monthlyStats.overviewEmpty');
|
||||
const periodText = t('workoutHistory.monthlyStats.periodText', { day: monthEndDay });
|
||||
const maxDuration = statsItems[0]?.duration || 1;
|
||||
|
||||
return (
|
||||
@@ -369,7 +384,7 @@ export default function WorkoutHistoryScreen() {
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.monthlyStatsCard}
|
||||
>
|
||||
<Text style={styles.statSectionLabel}>锻炼时间</Text>
|
||||
<Text style={styles.statSectionLabel}>{t('workoutHistory.monthlyStats.title')}</Text>
|
||||
<Text style={styles.statPeriodText}>{periodText}</Text>
|
||||
<Text style={styles.statDescription}>{overviewText}</Text>
|
||||
|
||||
@@ -403,7 +418,7 @@ export default function WorkoutHistoryScreen() {
|
||||
) : (
|
||||
<View style={styles.statEmptyState}>
|
||||
<MaterialCommunityIcons name="calendar-blank" size={20} color="#7C85A3" />
|
||||
<Text style={styles.statEmptyText}>本月还没有锻炼数据</Text>
|
||||
<Text style={styles.statEmptyText}>{t('workoutHistory.monthlyStats.emptyData')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</LinearGradient>
|
||||
@@ -416,8 +431,8 @@ export default function WorkoutHistoryScreen() {
|
||||
const emptyComponent = useMemo(() => (
|
||||
<View style={styles.emptyContainer}>
|
||||
<MaterialCommunityIcons name="calendar-blank" size={40} color="#9AA4C4" />
|
||||
<Text style={styles.emptyText}>暂无锻炼记录</Text>
|
||||
<Text style={styles.emptySubText}>完成一次锻炼后即可在此查看详细历史</Text>
|
||||
<Text style={styles.emptyText}>{t('workoutHistory.empty.title')}</Text>
|
||||
<Text style={styles.emptySubText}>{t('workoutHistory.empty.subtitle')}</Text>
|
||||
</View>
|
||||
), []);
|
||||
|
||||
@@ -453,7 +468,7 @@ export default function WorkoutHistoryScreen() {
|
||||
}
|
||||
|
||||
const activityLabel = getWorkoutTypeDisplayName(workout.workoutActivityType);
|
||||
return `这是你${workoutDate.format('M月')}的第 ${index + 1} 次${activityLabel}。`;
|
||||
return t('workoutHistory.monthOccurrence', { month: workoutDate.format('M月'), index: index + 1, activity: activityLabel });
|
||||
}, [sections]);
|
||||
|
||||
const loadWorkoutDetail = useCallback(async (workout: WorkoutData) => {
|
||||
@@ -463,16 +478,16 @@ export default function WorkoutHistoryScreen() {
|
||||
const metrics = await getWorkoutDetailMetrics(workout);
|
||||
setDetailMetrics(metrics);
|
||||
} catch (err) {
|
||||
console.error('加载锻炼详情失败:', err);
|
||||
console.error('Failed to load workout details:', err);
|
||||
setDetailMetrics(null);
|
||||
setDetailError('加载锻炼详情失败,请稍后再试');
|
||||
setDetailError(t('workoutHistory.error.detailLoadFailed'));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWorkoutPress = useCallback((workout: WorkoutData) => {
|
||||
const intensity = getIntensityBadge(workout.totalEnergyBurned, workout.duration || 0);
|
||||
const intensity = getIntensityBadge(t, workout.totalEnergyBurned, workout.duration || 0);
|
||||
setSelectedIntensity(intensity);
|
||||
setSelectedWorkout(workout);
|
||||
setDetailMetrics(null);
|
||||
@@ -482,6 +497,22 @@ export default function WorkoutHistoryScreen() {
|
||||
loadWorkoutDetail(workout);
|
||||
}, [computeMonthlyOccurrenceText, loadWorkoutDetail]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pendingWorkoutId || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allWorkouts = sections.flatMap((section) => section.data);
|
||||
const targetWorkout = allWorkouts.find((workout) => workout.id === pendingWorkoutId);
|
||||
|
||||
if (targetWorkout) {
|
||||
handleWorkoutPress(targetWorkout);
|
||||
}
|
||||
|
||||
// 清理待处理状态,避免重复触发
|
||||
setPendingWorkoutId(null);
|
||||
}, [pendingWorkoutId, isLoading, sections, handleWorkoutPress]);
|
||||
|
||||
const handleRetryDetail = useCallback(() => {
|
||||
if (selectedWorkout) {
|
||||
loadWorkoutDetail(selectedWorkout);
|
||||
@@ -495,7 +526,7 @@ export default function WorkoutHistoryScreen() {
|
||||
const renderItem = useCallback(({ item }: { item: WorkoutData }) => {
|
||||
const calories = Math.round(item.totalEnergyBurned || 0);
|
||||
const minutes = Math.max(Math.round((item.duration || 0) / 60), 1);
|
||||
const intensity = getIntensityBadge(item.totalEnergyBurned, item.duration || 0);
|
||||
const intensity = getIntensityBadge(t, item.totalEnergyBurned, item.duration || 0);
|
||||
const iconName = ICON_MAP[item.workoutActivityType as WorkoutActivityType] || 'arm-flex';
|
||||
const time = dayjs(item.startDate || item.endDate).format('HH:mm');
|
||||
const activityLabel = getWorkoutTypeDisplayName(item.workoutActivityType);
|
||||
@@ -512,12 +543,12 @@ export default function WorkoutHistoryScreen() {
|
||||
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={styles.cardTitle}>{calories}千卡 · {minutes}分钟</Text>
|
||||
<Text style={styles.cardTitle}>{t('workoutHistory.historyCard.calories', { calories, minutes })}</Text>
|
||||
<View style={[styles.intensityBadge, { backgroundColor: intensity.background }]}>
|
||||
<Text style={[styles.intensityText, { color: intensity.color }]}>{intensity.label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.cardSubtitle}>{activityLabel},{time}</Text>
|
||||
<Text style={styles.cardSubtitle}>{t('workoutHistory.historyCard.activityTime', { activity: activityLabel, time })}</Text>
|
||||
</View>
|
||||
|
||||
{/* <Ionicons name="chevron-forward" size={20} color="#9AA4C4" /> */}
|
||||
@@ -535,11 +566,11 @@ export default function WorkoutHistoryScreen() {
|
||||
colors={["#F3F5FF", "#FFFFFF"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<HeaderBar title="锻炼总结" variant="minimal" transparent={true} />
|
||||
<HeaderBar title={t('workoutHistory.title')} variant="minimal" transparent={true} />
|
||||
{isLoading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#5C55FF" />
|
||||
<Text style={styles.loadingText}>正在加载锻炼记录...</Text>
|
||||
<Text style={styles.loadingText}>{t('workoutHistory.loading')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
@@ -556,7 +587,7 @@ export default function WorkoutHistoryScreen() {
|
||||
<MaterialCommunityIcons name="alert-circle" size={40} color="#F85959" />
|
||||
<Text style={[styles.emptyText, { color: '#F85959' }]}>{error}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={loadHistory}>
|
||||
<Text style={styles.retryText}>重试</Text>
|
||||
<Text style={styles.retryText}>{t('workoutHistory.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : emptyComponent}
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -345,4 +348,4 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
BIN
assets/fonts/ali-bold.ttf
Normal file
BIN
assets/fonts/ali-bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ali-regular.ttf
Normal file
BIN
assets/fonts/ali-regular.ttf
Normal file
Binary file not shown.
BIN
assets/images/medicine/medicine-ai-summary.png
Normal file
BIN
assets/images/medicine/medicine-ai-summary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
assets/logo.png
BIN
assets/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 672 KiB |
1
assets/lottie/loading-blue.json
Normal file
1
assets/lottie/loading-blue.json
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/machine.png
Normal file
BIN
assets/machine.png
Normal file
Binary file not shown.
|
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>
|
||||
);
|
||||
|
||||
@@ -242,6 +242,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#0F172A',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
@@ -256,6 +257,7 @@ const styles = StyleSheet.create({
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
valueSection: {
|
||||
flexDirection: 'row',
|
||||
@@ -267,10 +269,12 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#0F172A',
|
||||
lineHeight: 28,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
unit: {
|
||||
fontSize: 12,
|
||||
color: '#64748B',
|
||||
marginLeft: 6,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, StyleSheet, View } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
import Svg, { Circle, Defs, Stop, LinearGradient as SvgLinearGradient } from 'react-native-svg';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
@@ -26,12 +26,8 @@ export function CalorieRingChart({
|
||||
protein,
|
||||
fat,
|
||||
carbs,
|
||||
proteinGoal,
|
||||
fatGoal,
|
||||
carbsGoal,
|
||||
|
||||
}: CalorieRingChartProps) {
|
||||
const surfaceColor = useThemeColor({}, 'surface');
|
||||
const { t } = useI18n();
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const textSecondaryColor = useThemeColor({}, 'textSecondary');
|
||||
|
||||
@@ -46,9 +42,9 @@ export function CalorieRingChart({
|
||||
const totalAvailable = metabolism + exercise;
|
||||
const progressPercentage = totalAvailable > 0 ? Math.min((consumed / totalAvailable) * 100, 100) : 0;
|
||||
|
||||
// 圆环参数 - 减小尺寸以优化空间占用
|
||||
const radius = 48;
|
||||
const strokeWidth = 8; // 增加圆环厚度
|
||||
// 圆环参数 - 缩小尺寸
|
||||
const radius = 42;
|
||||
const strokeWidth = 8;
|
||||
const center = radius + strokeWidth;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDasharray = circumference;
|
||||
@@ -70,34 +66,32 @@ export function CalorieRingChart({
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: surfaceColor }]}>
|
||||
{/* 左上角公式展示 */}
|
||||
<View style={styles.formulaContainer}>
|
||||
<ThemedText style={[styles.formulaText, { color: textSecondaryColor }]}>
|
||||
还能吃 = 代谢 + 运动 - 饮食
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<View style={styles.container}>
|
||||
<View style={styles.mainContent}>
|
||||
{/* 左侧圆环图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<Svg width={center * 2} height={center * 2}>
|
||||
<Defs>
|
||||
<SvgLinearGradient id="progressGradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={progressPercentage > 80 ? "#FF9966" : "#4facfe"} stopOpacity="1" />
|
||||
<Stop offset="1" stopColor={progressPercentage > 80 ? "#FF5E62" : "#00f2fe"} stopOpacity="1" />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
{/* 背景圆环 */}
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke="#F0F0F0"
|
||||
stroke="#F5F7FA"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
{/* 进度圆环 - 保持固定颜色 */}
|
||||
{/* 进度圆环 */}
|
||||
<AnimatedCircle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke={progressPercentage > 80 ? "#FF6B6B" : "#4ECDC4"}
|
||||
stroke="url(#progressGradient)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={`${strokeDasharray}`}
|
||||
@@ -109,68 +103,68 @@ export function CalorieRingChart({
|
||||
|
||||
{/* 中心内容 */}
|
||||
<View style={styles.centerContent}>
|
||||
<ThemedText style={[styles.centerLabel, { color: textSecondaryColor }]}>
|
||||
还能吃
|
||||
<ThemedText style={styles.centerLabel}>
|
||||
{t('nutritionRecords.chart.remaining')}
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.centerValue, { color: textColor }]}>
|
||||
{Math.round(canEat)}千卡
|
||||
<ThemedText style={styles.centerValue}>
|
||||
{Math.round(canEat)}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.centerUnit}>
|
||||
{t('nutritionRecords.nutrients.caloriesUnit')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧数据展示 */}
|
||||
{/* 右侧数据展示 - 优化布局 */}
|
||||
<View style={styles.dataContainer}>
|
||||
<View style={styles.dataBackground}>
|
||||
{/* 左右两列布局 */}
|
||||
<View style={styles.dataColumns}>
|
||||
{/* 左列:卡路里数据 */}
|
||||
<View style={styles.dataColumn}>
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>代谢</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{Math.round(metabolism)}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
{/* 公式 */}
|
||||
<View style={styles.formulaContainer}>
|
||||
<ThemedText style={styles.formulaText}>
|
||||
{t('nutritionRecords.chart.formula')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>运动</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{Math.round(exercise)}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>饮食</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{Math.round(consumed)}千卡
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右列:营养数据 */}
|
||||
<View style={styles.dataColumn}>
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>蛋白质</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{Math.round(protein)}g
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>脂肪</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{Math.round(fat)}g
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataItem}>
|
||||
<ThemedText style={[styles.dataLabel, { color: textSecondaryColor }]}>碳水</ThemedText>
|
||||
<ThemedText style={[styles.dataValue, { color: textColor }]}>
|
||||
{Math.round(carbs)}g
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
{/* 代谢 & 运动 & 饮食 */}
|
||||
<View style={styles.statsGroup}>
|
||||
<View style={styles.statRowCompact}>
|
||||
<View style={styles.labelWithDot}>
|
||||
<View style={styles.dotMetabolism} />
|
||||
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.metabolism')}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.statValue}>{Math.round(metabolism)}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.statRowCompact}>
|
||||
<View style={styles.labelWithDot}>
|
||||
<View style={styles.dotExercise} />
|
||||
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.exercise')}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.statValue}>{Math.round(exercise)}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.statRowCompact}>
|
||||
<View style={styles.labelWithDot}>
|
||||
<View style={styles.dotConsumed} />
|
||||
<ThemedText style={styles.statLabel}>{t('nutritionRecords.chart.diet')}</ThemedText>
|
||||
</View>
|
||||
<ThemedText style={styles.statValue}>{Math.round(consumed)}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* 营养素 - 水平排布 */}
|
||||
<View style={styles.nutritionRow}>
|
||||
<View style={styles.nutritionItem}>
|
||||
<ThemedText style={styles.statValueSmall}>{Math.round(protein)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
|
||||
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.protein')}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.nutritionItem}>
|
||||
<ThemedText style={styles.statValueSmall}>{Math.round(fat)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
|
||||
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.fat')}</ThemedText>
|
||||
</View>
|
||||
<View style={styles.nutritionItem}>
|
||||
<ThemedText style={styles.statValueSmall}>{Math.round(carbs)}{t('nutritionRecords.nutrients.unit')}</ThemedText>
|
||||
<ThemedText style={styles.statLabelSmall}>{t('nutritionRecords.nutrients.carbs')}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -181,40 +175,35 @@ export function CalorieRingChart({
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
marginHorizontal: 20,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
formulaContainer: {
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12,
|
||||
},
|
||||
formulaText: {
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: '#999999',
|
||||
lineHeight: 16,
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
mainContent: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 0, // 移除底部间距,因为不再有底部营养容器
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
chartContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 112, // 减少宽度以匹配更小的圆环 (48*2 + 8*2)
|
||||
flexShrink: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
marginTop: 8,
|
||||
},
|
||||
centerContent: {
|
||||
position: 'absolute',
|
||||
@@ -222,71 +211,95 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
},
|
||||
centerLabel: {
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: '#999999',
|
||||
marginBottom: 2,
|
||||
color: '#94A3B8',
|
||||
marginBottom: 1,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
centerValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
marginBottom: 1,
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
color: '#1E293B',
|
||||
lineHeight: 24,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
centerPercentage: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
color: '#999999',
|
||||
centerUnit: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
dataContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
marginLeft: 20,
|
||||
},
|
||||
dataBackground: {
|
||||
backgroundColor: 'rgba(248, 250, 252, 0.8)', // 毛玻璃背景色
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
// 添加边框增强毛玻璃效果
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
gap: 4,
|
||||
statsGroup: {
|
||||
gap: 6,
|
||||
},
|
||||
dataItem: {
|
||||
statRowCompact: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
dataIcon: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
labelWithDot: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
dataLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
color: '#999999',
|
||||
minWidth: 28,
|
||||
dotMetabolism: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#94A3B8',
|
||||
marginRight: 6,
|
||||
},
|
||||
dataValue: {
|
||||
fontSize: 11,
|
||||
dotExercise: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#4facfe',
|
||||
marginRight: 6,
|
||||
},
|
||||
dotConsumed: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: '#FF9966',
|
||||
marginRight: 6,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#64748B',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
color: '#334155',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
dataColumns: {
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#F1F5F9',
|
||||
marginVertical: 10,
|
||||
},
|
||||
nutritionRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
dataColumn: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
nutritionItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statLabelSmall: {
|
||||
fontSize: 10,
|
||||
color: '#94A3B8',
|
||||
marginTop: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
statValueSmall: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getMonthDaysZh, getMonthTitleZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { getMonthDays, getMonthTitle, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -50,6 +51,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
autoScrollToSelected = true,
|
||||
showCalendarIcon = true,
|
||||
}) => {
|
||||
const { t, i18n } = useI18n();
|
||||
|
||||
// 内部状态管理
|
||||
const [internalSelectedIndex, setInternalSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs()); // 当前显示的月份
|
||||
@@ -59,8 +62,8 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const isGlassAvailable = isLiquidGlassAvailable();
|
||||
|
||||
// 获取日期数据
|
||||
const days = getMonthDaysZh(currentMonth);
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitleZh(currentMonth);
|
||||
const days = getMonthDays(currentMonth, i18n.language as 'zh' | 'en');
|
||||
const monthTitle = externalMonthTitle ?? getMonthTitle(currentMonth, i18n.language as 'zh' | 'en');
|
||||
|
||||
// 判断当前选中的日期是否是今天
|
||||
const isSelectedDateToday = () => {
|
||||
@@ -201,7 +204,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
setCurrentMonth(selectedMonth);
|
||||
|
||||
// 计算选中日期在新月份中的索引
|
||||
const newMonthDays = getMonthDaysZh(selectedMonth);
|
||||
const newMonthDays = getMonthDays(selectedMonth, i18n.language as 'zh' | 'en');
|
||||
const selectedDay = selectedMonth.date();
|
||||
const newSelectedIndex = newMonthDays.findIndex(day => day.dayOfMonth === selectedDay);
|
||||
|
||||
@@ -219,7 +222,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
const handleGoToday = () => {
|
||||
const today = dayjs();
|
||||
setCurrentMonth(today);
|
||||
const todayDays = getMonthDaysZh(today);
|
||||
const todayDays = getMonthDays(today, i18n.language as 'zh' | 'en');
|
||||
const newSelectedIndex = todayDays.findIndex(day => day.dayOfMonth === today.date());
|
||||
|
||||
if (newSelectedIndex !== -1) {
|
||||
@@ -250,11 +253,11 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
tintColor="rgba(124, 58, 237, 0.08)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.todayButton, styles.todayButtonFallback]}>
|
||||
<Text style={styles.todayButtonText}>回到今天</Text>
|
||||
<Text style={styles.todayButtonText}>{t('dateSelector.backToToday')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -379,7 +382,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||
maximumDate={disableFutureDates ? new Date() : undefined}
|
||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
@@ -395,12 +398,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -413,7 +416,7 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||
minimumDate={dayjs().subtract(6, 'month').toDate()}
|
||||
maximumDate={disableFutureDates ? new Date() : undefined}
|
||||
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||
{...(Platform.OS === 'ios' ? { locale: i18n.language === 'zh' ? 'zh-CN' : 'en-US' } : {})}
|
||||
onChange={(event, date) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (date) setPickerDate(date);
|
||||
@@ -429,12 +432,12 @@ export const DateSelector: React.FC<DateSelectorProps> = ({
|
||||
{Platform.OS === 'ios' && (
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||
<Text style={styles.modalBtnText}>取消</Text>
|
||||
<Text style={styles.modalBtnText}>{t('dateSelector.cancel')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => {
|
||||
onConfirmDate(pickerDate);
|
||||
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</Text>
|
||||
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('dateSelector.confirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -460,15 +463,16 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
monthTitle: {
|
||||
fontSize: 22,
|
||||
fontSize: 26,
|
||||
fontWeight: '800',
|
||||
color: '#1a1a1a',
|
||||
letterSpacing: -0.5,
|
||||
color: '#1c1f3a',
|
||||
fontFamily: 'AliBold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
calendarIconButton: {
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
marginLeft: 4,
|
||||
padding: 6,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
calendarIconFallback: {
|
||||
@@ -477,22 +481,20 @@ const styles = StyleSheet.create({
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
todayButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 12,
|
||||
marginRight: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
marginRight: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
todayButtonFallback: {
|
||||
backgroundColor: '#EEF2FF',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124, 58, 237, 0.2)',
|
||||
},
|
||||
todayButtonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#7c3aed',
|
||||
letterSpacing: 0.2,
|
||||
color: '#5F6BF0',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
daysContainer: {
|
||||
paddingBottom: 8,
|
||||
@@ -503,8 +505,8 @@ const styles = StyleSheet.create({
|
||||
marginRight: 8,
|
||||
},
|
||||
dayPill: {
|
||||
width: 40,
|
||||
height: 60,
|
||||
width: 48,
|
||||
height: 68,
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -518,14 +520,12 @@ const styles = StyleSheet.create({
|
||||
transform: [{ scale: 0.96 }],
|
||||
},
|
||||
dayPillSelectedFallback: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.5)',
|
||||
backgroundColor: '#5F6BF0',
|
||||
shadowColor: 'rgba(95, 107, 240, 0.3)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
dayPillDisabled: {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -533,27 +533,31 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
dayLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#8e8e93',
|
||||
marginBottom: 2,
|
||||
letterSpacing: 0.1,
|
||||
fontWeight: '600',
|
||||
color: '#94A3B8',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
dayLabelSelected: {
|
||||
color: '#1a1a1a',
|
||||
fontWeight: '800',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
opacity: 0.9,
|
||||
},
|
||||
dayLabelDisabled: {
|
||||
color: '#c7c7cc',
|
||||
},
|
||||
dayDate: {
|
||||
fontSize: 13,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
color: '#8e8e93',
|
||||
letterSpacing: -0.2,
|
||||
color: '#64748B',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
dayDateSelected: {
|
||||
color: '#1a1a1a',
|
||||
fontWeight: '800',
|
||||
fontSize: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
dayDateDisabled: {
|
||||
color: '#c7c7cc',
|
||||
@@ -607,11 +611,13 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.1,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
modalBtnTextPrimary: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.1,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { ActivityRingsData, fetchActivityRingsForDate } from '@/utils/health';
|
||||
@@ -26,6 +27,7 @@ export function FitnessRingsCard({
|
||||
selectedDate,
|
||||
resetToken,
|
||||
}: FitnessRingsCardProps) {
|
||||
const { t } = useI18n();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenges = useAppSelector(selectChallengeList);
|
||||
const [activityData, setActivityData] = useState<ActivityRingsData | null>(null);
|
||||
@@ -135,6 +137,24 @@ export function FitnessRingsCard({
|
||||
const exerciseProgress = Math.min(1, Math.max(0, exerciseMinutes / exerciseMinutesGoal));
|
||||
const standProgress = Math.min(1, Math.max(0, standHours / standHoursGoal));
|
||||
|
||||
const units = useMemo(
|
||||
() => ({
|
||||
kcal: t('statistics.components.fitness.kcal'),
|
||||
minutes: t('statistics.components.fitness.minutes'),
|
||||
hours: t('statistics.components.fitness.hours'),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const fitnessRows = useMemo(
|
||||
() => [
|
||||
{ key: 'active', value: Math.round(activeCalories), goal: activeCaloriesGoal, unit: units.kcal },
|
||||
{ key: 'exercise', value: Math.round(exerciseMinutes), goal: exerciseMinutesGoal, unit: units.minutes },
|
||||
{ key: 'stand', value: Math.round(standHours), goal: standHoursGoal, unit: units.hours },
|
||||
],
|
||||
[activeCalories, activeCaloriesGoal, exerciseMinutes, exerciseMinutesGoal, standHours, standHoursGoal, units]
|
||||
);
|
||||
|
||||
const handlePress = () => {
|
||||
router.push(ROUTES.FITNESS_RINGS_DETAIL);
|
||||
};
|
||||
@@ -191,47 +211,23 @@ export function FitnessRingsCard({
|
||||
|
||||
{/* 右侧数据显示 */}
|
||||
<View style={styles.dataContainer}>
|
||||
<View style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{Math.round(activeCalories)}</Text>
|
||||
<Text style={styles.dataGoal}>/{activeCaloriesGoal}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>千卡</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{Math.round(exerciseMinutes)}</Text>
|
||||
<Text style={styles.dataGoal}>/{exerciseMinutesGoal}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>分钟</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{Math.round(standHours)}</Text>
|
||||
<Text style={styles.dataGoal}>/{standHoursGoal}</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>小时</Text>
|
||||
</View>
|
||||
{fitnessRows.map((row) => (
|
||||
<View key={row.key} style={styles.dataRow}>
|
||||
<Text style={styles.dataText}>
|
||||
{loading ? (
|
||||
<Text style={styles.dataValue}>--</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.dataValue}>{row.value}</Text>
|
||||
<Text style={styles.dataGoal}>
|
||||
{t('statistics.components.fitnessRings.goal', { goal: row.goal })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={styles.dataUnit}>{row.unit}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -285,6 +281,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
dataValue: {
|
||||
color: '#192126',
|
||||
@@ -298,5 +295,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
minWidth: 25,
|
||||
textAlign: 'right',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useRouter } from 'expo-router';
|
||||
@@ -20,6 +21,7 @@ interface FloatingFoodOverlayProps {
|
||||
|
||||
export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: FloatingFoodOverlayProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { pushIfAuthedElseLogin } = useAuthGuard()
|
||||
|
||||
@@ -41,21 +43,21 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'scan',
|
||||
title: 'AI识别',
|
||||
title: t('nutritionRecords.overlay.scan'),
|
||||
icon: '📷',
|
||||
backgroundColor: '#4FC3F7',
|
||||
onPress: handlePhotoRecognition,
|
||||
},
|
||||
{
|
||||
id: 'food-library',
|
||||
title: '食物库',
|
||||
title: t('nutritionRecords.overlay.foodLibrary'),
|
||||
icon: '🍎',
|
||||
backgroundColor: '#FF9500',
|
||||
onPress: handleFoodLibrary,
|
||||
},
|
||||
{
|
||||
id: 'voice-record',
|
||||
title: '一句话记录',
|
||||
title: t('nutritionRecords.overlay.voiceRecord'),
|
||||
icon: '🎤',
|
||||
backgroundColor: '#7B68EE',
|
||||
onPress: handleVoiceRecord,
|
||||
@@ -81,7 +83,7 @@ export function FloatingFoodOverlay({ visible, onClose, mealType = 'dinner' }: F
|
||||
<View style={styles.container}>
|
||||
<BlurView intensity={80} tint="light" style={styles.blurContainer}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>记录方式</Text>
|
||||
<Text style={styles.title}>{t('nutritionRecords.overlay.title')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuGrid}>
|
||||
|
||||
176
components/MembershipBanner.tsx
Normal file
176
components/MembershipBanner.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MembershipBannerProps {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MembershipBanner: React.FC<MembershipBannerProps> = ({ onPress }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onPress}
|
||||
style={styles.touchable}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#4C3AFF', '#8D5BEA']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
{/* Decorative Elements */}
|
||||
<View style={styles.decorationCircleLarge} />
|
||||
<View style={styles.decorationCircleSmall} />
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.textContainer}>
|
||||
<View style={styles.badgeContainer}>
|
||||
<Text style={styles.badgeText}>PRO</Text>
|
||||
</View>
|
||||
<Text style={styles.title}>
|
||||
{t('personal.membershipBanner.title', 'Unlock Premium Access')}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{t('personal.membershipBanner.subtitle', 'Get unlimited access to all features')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.ctaButton}>
|
||||
<Text style={styles.ctaText}>{t('personal.membershipBanner.cta', 'Upgrade')}</Text>
|
||||
<Ionicons name="arrow-forward" size={12} color="#4C3AFF" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.illustrationContainer}>
|
||||
{/* Use Ionicons as illustration or you can use Image if passed as prop */}
|
||||
<Ionicons name="diamond-outline" size={56} color="rgba(255,255,255,0.15)" />
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 20,
|
||||
borderRadius: 16,
|
||||
// Premium Shadow
|
||||
shadowColor: '#4C3AFF',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 6,
|
||||
marginHorizontal: 4, // Add margin to avoid cutting off shadow
|
||||
},
|
||||
touchable: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
gradient: {
|
||||
padding: 16,
|
||||
minHeight: 100,
|
||||
position: 'relative',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
decorationCircleLarge: {
|
||||
position: 'absolute',
|
||||
top: -40,
|
||||
right: -40,
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: 80,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
decorationCircleSmall: {
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
contentContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
zIndex: 1,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
paddingRight: 12,
|
||||
zIndex: 2,
|
||||
},
|
||||
badgeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: 8,
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
badgeIcon: {
|
||||
marginRight: 3,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFD700',
|
||||
fontSize: 9,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 20,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
marginBottom: 12,
|
||||
lineHeight: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
ctaButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
alignSelf: 'flex-start',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
ctaText: {
|
||||
color: '#4C3AFF',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
marginRight: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'absolute',
|
||||
right: -6,
|
||||
bottom: -6,
|
||||
zIndex: 1,
|
||||
transform: [{ rotate: '-15deg' }]
|
||||
}
|
||||
});
|
||||
@@ -13,7 +13,7 @@ interface MoodCardProps {
|
||||
|
||||
export function MoodCard({ moodCheckin, onPress }: MoodCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType) : null;
|
||||
const moodConfig = moodCheckin ? getMoodConfig(moodCheckin.moodType, t) : null;
|
||||
const animationRef = useRef<LottieView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,7 +82,8 @@ const styles = StyleSheet.create({
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
lottieAnimation: {
|
||||
@@ -100,21 +101,25 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#059669',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
moodPreviewTime: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
moodEmptyText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 22,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
moodLoadingText: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 22,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { MoodCheckin, getMoodConfig } from '@/services/moodCheckins';
|
||||
import dayjs from 'dayjs';
|
||||
import React from 'react';
|
||||
@@ -8,7 +9,9 @@ interface MoodHistoryCardProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHistoryCardProps) {
|
||||
export function MoodHistoryCard({ moodCheckins, title }: MoodHistoryCardProps) {
|
||||
const { t } = useI18n();
|
||||
const defaultTitle = t('mood.history.title');
|
||||
// 计算心情统计
|
||||
const moodStats = React.useMemo(() => {
|
||||
const stats = {
|
||||
@@ -26,7 +29,7 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
|
||||
|
||||
// 计算心情分布
|
||||
moodCheckins.forEach(checkin => {
|
||||
const moodLabel = getMoodConfig(checkin.moodType)?.label || checkin.moodType;
|
||||
const moodLabel = getMoodConfig(checkin.moodType, t)?.label || checkin.moodType;
|
||||
stats.moodDistribution[moodLabel] = (stats.moodDistribution[moodLabel] || 0) + 1;
|
||||
});
|
||||
|
||||
@@ -45,11 +48,11 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.title}>{title || defaultTitle}</Text>
|
||||
|
||||
{moodCheckins.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyText}>暂无心情记录</Text>
|
||||
<Text style={styles.emptyText}>{t('mood.history.noRecords')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
@@ -57,36 +60,36 @@ export function MoodHistoryCard({ moodCheckins, title = '心情记录' }: MoodHi
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{moodStats.total}</Text>
|
||||
<Text style={styles.statLabel}>总记录</Text>
|
||||
<Text style={styles.statLabel}>{t('mood.history.totalRecords')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{moodStats.averageIntensity}</Text>
|
||||
<Text style={styles.statLabel}>平均强度</Text>
|
||||
<Text style={styles.statLabel}>{t('mood.history.averageIntensity')}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statValue}>{moodStats.mostFrequentMood}</Text>
|
||||
<Text style={styles.statLabel}>最常见</Text>
|
||||
<Text style={styles.statLabel}>{t('mood.history.mostFrequent')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 最近记录 */}
|
||||
<View style={styles.recentContainer}>
|
||||
<Text style={styles.sectionTitle}>最近记录</Text>
|
||||
<Text style={styles.sectionTitle}>{t('mood.history.recentRecords')}</Text>
|
||||
{recentMoods.map((checkin, index) => {
|
||||
const moodConfig = getMoodConfig(checkin.moodType);
|
||||
const moodConfig = getMoodConfig(checkin.moodType, t);
|
||||
return (
|
||||
<View key={checkin.id} style={styles.moodItem}>
|
||||
<View style={styles.moodInfo}>
|
||||
<Text style={styles.moodEmoji}>{moodConfig?.emoji}</Text>
|
||||
<Text style={styles.moodEmoji}>😊</Text>
|
||||
<View style={styles.moodDetails}>
|
||||
<Text style={styles.moodLabel}>{moodConfig?.label}</Text>
|
||||
<Text style={styles.moodDate}>
|
||||
{dayjs(checkin.createdAt).format('MM月DD日 HH:mm')}
|
||||
{dayjs(checkin.createdAt).format(t('mood.history.dateTimeFormat'))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.moodIntensity}>
|
||||
<Text style={styles.intensityText}>强度 {checkin.intensity}</Text>
|
||||
<Text style={styles.intensityText}>{t('mood.history.intensity')} {checkin.intensity}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
@@ -38,6 +39,7 @@ export default function MoodIntensitySlider({
|
||||
width = 320,
|
||||
height = 16, // 更粗的进度条
|
||||
}: MoodIntensitySliderProps) {
|
||||
const { t } = useTranslation();
|
||||
const thumbSize = 32; // 合适的触摸区域
|
||||
const translateX = useSharedValue(0);
|
||||
const isDragging = useSharedValue(0);
|
||||
@@ -175,8 +177,8 @@ export default function MoodIntensitySlider({
|
||||
|
||||
{/* 标签 */}
|
||||
<View style={[styles.labelsContainer, { width: width }]}>
|
||||
<Text style={styles.labelText}>轻微</Text>
|
||||
<Text style={styles.labelText}>强烈</Text>
|
||||
<Text style={styles.labelText}>{t('mood.edit.intensityLow')}</Text>
|
||||
<Text style={styles.labelText}>{t('mood.edit.intensityHigh')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 刻度 */}
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { useI18n } from '../hooks/useI18n';
|
||||
import { useNotifications } from '../hooks/useNotifications';
|
||||
import { ThemedText } from './ThemedText';
|
||||
import { ThemedView } from './ThemedView';
|
||||
|
||||
export const NotificationTest: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
isInitialized,
|
||||
permissionStatus,
|
||||
@@ -95,8 +97,8 @@ export const NotificationTest: React.FC = () => {
|
||||
|
||||
const handleSendMoodCheckinReminder = async () => {
|
||||
try {
|
||||
await sendMoodCheckinReminder('心情打卡', '记得记录今天的心情状态哦');
|
||||
Alert.alert('成功', '心情打卡提醒已发送');
|
||||
await sendMoodCheckinReminder(t('notifications.moodReminder.title'), t('notifications.moodReminder.body'));
|
||||
Alert.alert(t('common.success'), t('notifications.moodReminder.sent'));
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '发送心情打卡提醒失败');
|
||||
}
|
||||
|
||||
@@ -82,10 +82,10 @@ const SimpleRingProgress = ({
|
||||
/>
|
||||
</Svg>
|
||||
<View style={{ position: 'absolute', alignItems: 'center', justifyContent: 'center', top: 0, left: 0, right: 0, bottom: 0 }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126' }}>
|
||||
<Text style={{ fontSize: 12, fontWeight: '600', color: '#192126', fontFamily: 'AliBold' }}>
|
||||
{Math.round(remainingCalories)}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE' }}>{t('statistics.components.diet.remaining')}</Text>
|
||||
<Text style={{ fontSize: 8, color: '#9AA3AE', fontFamily: 'AliRegular' }}>{t('statistics.components.diet.remaining')}</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 [
|
||||
@@ -358,12 +361,14 @@ const styles = StyleSheet.create({
|
||||
cardTitle: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 10,
|
||||
color: '#9AA3AE',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
contentContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -416,11 +421,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 10,
|
||||
color: '#9AA3AE',
|
||||
flex: 1,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 12,
|
||||
color: '#192126',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
// 卡路里相关样式
|
||||
calorieSection: {
|
||||
@@ -439,6 +446,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
calorieContent: {
|
||||
},
|
||||
@@ -447,6 +455,7 @@ const styles = StyleSheet.create({
|
||||
color: '#64748B',
|
||||
fontWeight: '600',
|
||||
marginRight: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
calculationRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -458,11 +467,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
calculationText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#64748B',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
calculationItem: {
|
||||
flexDirection: 'row',
|
||||
@@ -473,11 +484,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 9,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
calculationValue: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
remainingCaloriesContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -488,6 +501,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
mealsContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -511,6 +525,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 10,
|
||||
color: '#64748B',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
// 食物选项样式
|
||||
foodOptionsContainer: {
|
||||
@@ -556,5 +571,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '500',
|
||||
color: '#192126',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { DietRecord } from '@/services/dietRecords';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -15,14 +16,6 @@ export type NutritionRecordCardProps = {
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
const MEAL_TYPE_LABELS = {
|
||||
breakfast: '早餐',
|
||||
lunch: '午餐',
|
||||
dinner: '晚餐',
|
||||
snack: '加餐',
|
||||
other: '其他',
|
||||
} as const;
|
||||
|
||||
const MEAL_TYPE_ICONS = {
|
||||
breakfast: 'sunny-outline',
|
||||
lunch: 'partly-sunny-outline',
|
||||
@@ -44,46 +37,40 @@ export function NutritionRecordCard({
|
||||
onPress,
|
||||
onDelete
|
||||
}: NutritionRecordCardProps) {
|
||||
const surfaceColor = useThemeColor({}, 'surface');
|
||||
const { t } = useI18n();
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const textSecondaryColor = useThemeColor({}, 'textSecondary');
|
||||
|
||||
// Popover 状态管理
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const popoverRef = useRef<any>(null);
|
||||
|
||||
// 左滑删除相关
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
|
||||
// 添加滑动状态管理,防止滑动时触发点击事件
|
||||
const [isSwiping, setIsSwiping] = useState(false);
|
||||
|
||||
// 营养数据统计
|
||||
const nutritionStats = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: '蛋白质',
|
||||
value: record.proteinGrams ? `${record.proteinGrams.toFixed(1)}g` : '-',
|
||||
icon: '🥩',
|
||||
color: '#FF6B6B'
|
||||
label: t('nutritionRecords.nutrients.protein'),
|
||||
value: record.proteinGrams ? `${Math.round(record.proteinGrams)}` : '-',
|
||||
unit: t('nutritionRecords.nutrients.unit'),
|
||||
color: '#64748B'
|
||||
},
|
||||
{
|
||||
label: '脂肪',
|
||||
value: record.fatGrams ? `${record.fatGrams.toFixed(1)}g` : '-',
|
||||
icon: '🥑',
|
||||
color: '#FFB366'
|
||||
label: t('nutritionRecords.nutrients.fat'),
|
||||
value: record.fatGrams ? `${Math.round(record.fatGrams)}` : '-',
|
||||
unit: t('nutritionRecords.nutrients.unit'),
|
||||
color: '#64748B'
|
||||
},
|
||||
{
|
||||
label: '碳水',
|
||||
value: record.carbohydrateGrams ? `${record.carbohydrateGrams.toFixed(1)}g` : '-',
|
||||
icon: '🍞',
|
||||
color: '#4ECDC4'
|
||||
label: t('nutritionRecords.nutrients.carbs'),
|
||||
value: record.carbohydrateGrams ? `${Math.round(record.carbohydrateGrams)}` : '-',
|
||||
unit: t('nutritionRecords.nutrients.unit'),
|
||||
color: '#64748B'
|
||||
},
|
||||
];
|
||||
}, [record]);
|
||||
}, [record, t]);
|
||||
|
||||
const mealTypeColor = MEAL_TYPE_COLORS[record.mealType];
|
||||
const mealTypeLabel = MEAL_TYPE_LABELS[record.mealType];
|
||||
const mealTypeLabel = t(`nutritionRecords.mealTypes.${record.mealType}`);
|
||||
|
||||
// 处理点击事件,只有在非滑动状态下才触发
|
||||
const handlePress = () => {
|
||||
@@ -92,31 +79,17 @@ export function NutritionRecordCard({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理滑动开始
|
||||
const handleSwipeableWillOpen = () => {
|
||||
setIsSwiping(true);
|
||||
};
|
||||
const handleSwipeableWillOpen = () => setIsSwiping(true);
|
||||
const handleSwipeableClose = () => setTimeout(() => setIsSwiping(false), 100);
|
||||
|
||||
// 处理滑动结束
|
||||
const handleSwipeableClose = () => {
|
||||
// 延迟重置滑动状态,防止滑动结束时立即触发点击
|
||||
setTimeout(() => {
|
||||
setIsSwiping(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 处理删除操作
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
`确定要删除这条营养记录吗?此操作无法撤销。`,
|
||||
t('nutritionRecords.delete.title'),
|
||||
t('nutritionRecords.delete.message'),
|
||||
[
|
||||
{ text: t('nutritionRecords.delete.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
text: t('nutritionRecords.delete.confirm'),
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete?.();
|
||||
@@ -127,7 +100,6 @@ export function NutritionRecordCard({
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染删除按钮
|
||||
const renderRightActions = () => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
@@ -136,7 +108,6 @@ export function NutritionRecordCard({
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="trash" size={20} color="#FFFFFF" />
|
||||
<Text style={styles.deleteButtonText}>删除</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -152,239 +123,228 @@ export function NutritionRecordCard({
|
||||
onSwipeableClose={handleSwipeableClose}
|
||||
>
|
||||
<RectButton
|
||||
style={[
|
||||
styles.card,
|
||||
|
||||
]}
|
||||
style={styles.card}
|
||||
onPress={handlePress}
|
||||
// activeOpacity={0.7}
|
||||
>
|
||||
{/* 主要内容区域 - 水平布局 */}
|
||||
<View style={styles.mainContent}>
|
||||
{/* 左侧:食物图片 */}
|
||||
<View style={[styles.foodImageContainer, !record.imageUrl && styles.foodImagePlaceholder]}>
|
||||
{record.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: record.imageUrl }}
|
||||
style={styles.foodImage}
|
||||
cachePolicy={'memory-disk'}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons name="restaurant" size={28} color={textSecondaryColor} />
|
||||
)}
|
||||
{/* 左侧:时间线和图标 */}
|
||||
<View style={styles.leftSection}>
|
||||
<View style={styles.mealIconContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-food.png')}
|
||||
style={styles.mealIcon}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 中间:食物信息 */}
|
||||
<View style={styles.foodInfoContainer}>
|
||||
{/* 食物名称 */}
|
||||
<ThemedText style={[styles.foodName, { color: textColor }]}>
|
||||
{record.foodName}
|
||||
</ThemedText>
|
||||
|
||||
{/* 时间 */}
|
||||
<ThemedText style={[styles.mealTime, { color: textSecondaryColor }]}>
|
||||
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
|
||||
</ThemedText>
|
||||
|
||||
{/* 营养信息 - 水平排列 */}
|
||||
<View style={styles.nutritionContainer}>
|
||||
{/* 中间:主要信息 */}
|
||||
<View style={styles.centerSection}>
|
||||
<View style={styles.titleRow}>
|
||||
<ThemedText style={styles.foodName} numberOfLines={1}>
|
||||
{record.foodName}
|
||||
</ThemedText>
|
||||
<View style={[styles.mealTag, { backgroundColor: `${mealTypeColor}15` }]}>
|
||||
<Text style={[styles.mealTagText, { color: mealTypeColor }]}>{mealTypeLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<Ionicons name="time-outline" size={12} color="#94A3B8" />
|
||||
<Text style={styles.timeText}>
|
||||
{record.mealTime ? dayjs(record.mealTime).format('HH:mm') : '--:--'}
|
||||
</Text>
|
||||
{record.portionDescription && (
|
||||
<>
|
||||
<Text style={styles.dotSeparator}>·</Text>
|
||||
<Text style={styles.portionText} numberOfLines={1}>{record.portionDescription}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 营养微缩信息 */}
|
||||
<View style={styles.nutritionRow}>
|
||||
{nutritionStats.map((stat, index) => (
|
||||
<View key={stat.label} style={styles.nutritionItem}>
|
||||
<ThemedText style={styles.nutritionIcon}>{stat.icon}</ThemedText>
|
||||
<ThemedText style={[styles.nutritionValue, { color: textColor }]}>
|
||||
{stat.value}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View key={index} style={styles.nutritionItem}>
|
||||
<Text style={styles.nutritionValue}>{stat.value}<Text style={styles.nutritionUnit}>{stat.unit}</Text></Text>
|
||||
<Text style={styles.nutritionLabel}>{stat.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧:热量和餐次标签 */}
|
||||
{/* 右侧:热量 */}
|
||||
<View style={styles.rightSection}>
|
||||
{/* 热量显示 */}
|
||||
<View style={styles.caloriesContainer}>
|
||||
<ThemedText style={[styles.caloriesText]}>
|
||||
{record.estimatedCalories ? `${Math.round(record.estimatedCalories)} kcal` : '- kcal'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 餐次标签 */}
|
||||
<View style={[styles.mealTypeBadge]}>
|
||||
<ThemedText style={[styles.mealTypeText, { color: mealTypeColor }]}>
|
||||
{mealTypeLabel}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<Text style={styles.caloriesValue}>
|
||||
{record.estimatedCalories ? Math.round(record.estimatedCalories) : '-'}
|
||||
</Text>
|
||||
<Text style={styles.caloriesUnit}>{t('nutritionRecords.nutrients.caloriesUnit')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 如果有图片,显示图片缩略图 */}
|
||||
{record.imageUrl && (
|
||||
<View style={styles.imageSection}>
|
||||
<Image
|
||||
source={{ uri: record.imageUrl }}
|
||||
style={styles.foodImage}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</RectButton>
|
||||
</Swipeable>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
// iOS 阴影效果 - 更自然的阴影
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 24,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.05)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
// Android 阴影效果
|
||||
elevation: 3,
|
||||
shadowRadius: 12,
|
||||
elevation: 2,
|
||||
},
|
||||
card: {
|
||||
flex: 1,
|
||||
minHeight: 100,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
padding: 16,
|
||||
},
|
||||
mainContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
leftSection: {
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
foodImageContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
marginRight: 16,
|
||||
mealIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#F8FAFC',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
mealIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
opacity: 0.8,
|
||||
},
|
||||
centerSection: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
gap: 8,
|
||||
},
|
||||
foodName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#1E293B',
|
||||
fontFamily: 'AliBold',
|
||||
flexShrink: 1,
|
||||
},
|
||||
mealTag: {
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 6,
|
||||
},
|
||||
mealTagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 10,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12,
|
||||
color: '#94A3B8',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
dotSeparator: {
|
||||
marginHorizontal: 4,
|
||||
color: '#CBD5E1',
|
||||
},
|
||||
portionText: {
|
||||
fontSize: 12,
|
||||
color: '#64748B',
|
||||
fontFamily: 'AliRegular',
|
||||
flex: 1,
|
||||
},
|
||||
nutritionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
nutritionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
gap: 2,
|
||||
},
|
||||
nutritionValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
nutritionUnit: {
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
color: '#94A3B8',
|
||||
marginLeft: 1,
|
||||
},
|
||||
nutritionLabel: {
|
||||
fontSize: 10,
|
||||
color: '#94A3B8',
|
||||
marginLeft: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
rightSection: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
paddingTop: 2,
|
||||
},
|
||||
caloriesValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: '#1E293B',
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 22,
|
||||
},
|
||||
caloriesUnit: {
|
||||
fontSize: 10,
|
||||
color: '#94A3B8',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
imageSection: {
|
||||
marginTop: 12,
|
||||
height: 120,
|
||||
width: '100%',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
foodImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 8,
|
||||
},
|
||||
foodImagePlaceholder: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
foodInfoContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
foodName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333333',
|
||||
lineHeight: 20,
|
||||
},
|
||||
mealTime: {
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
color: '#999999',
|
||||
lineHeight: 16,
|
||||
},
|
||||
nutritionContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginTop: 2,
|
||||
},
|
||||
nutritionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
nutritionIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
nutritionValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#666666',
|
||||
},
|
||||
rightSection: {
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
minHeight: 60,
|
||||
},
|
||||
caloriesContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
caloriesText: {
|
||||
fontSize: 14,
|
||||
color: '#333333',
|
||||
fontWeight: '600',
|
||||
},
|
||||
mealTypeBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
mealTypeText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
moreButton: {
|
||||
padding: 2,
|
||||
},
|
||||
notesSection: {
|
||||
marginTop: 8,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.06)',
|
||||
},
|
||||
notesText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
lineHeight: 18,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
popoverContainer: {
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
// iOS 阴影效果
|
||||
shadowColor: '#000000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
// Android 阴影效果
|
||||
elevation: 8,
|
||||
// 添加边框
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
popoverBackground: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
popoverContent: {
|
||||
minWidth: 140,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
popoverItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
gap: 12,
|
||||
},
|
||||
popoverText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#EF4444',
|
||||
backgroundColor: '#FF6B6B',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: 4,
|
||||
width: 70,
|
||||
height: '100%',
|
||||
borderRadius: 24,
|
||||
marginLeft: 12,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
Animated,
|
||||
InteractionManager,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle
|
||||
} from 'react-native';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { ChallengeType } from '@/services/challengesApi';
|
||||
import { reportChallengeProgress, selectChallengeList } from '@/store/challengesSlice';
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -20,8 +23,8 @@ import { AnimatedNumber } from './AnimatedNumber';
|
||||
// import Svg, { Rect } from 'react-native-svg';
|
||||
|
||||
interface StepsCardProps {
|
||||
curDate: Date
|
||||
stepGoal: number;
|
||||
curDate: Date;
|
||||
stepGoal?: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
@@ -31,9 +34,20 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const challenges = useAppSelector(selectChallengeList);
|
||||
|
||||
const [stepCount, setStepCount] = useState(0)
|
||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
|
||||
const [stepCount, setStepCount] = useState(0);
|
||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([]);
|
||||
|
||||
// 过滤出已参加的步数挑战
|
||||
const joinedStepsChallenges = useMemo(
|
||||
() => challenges.filter((challenge) => challenge.type === ChallengeType.STEP && challenge.isJoined && challenge.status === 'ongoing'),
|
||||
[challenges]
|
||||
);
|
||||
|
||||
// 跟踪上次上报的记录,避免重复上报
|
||||
const lastReportedRef = useRef<{ date: string; value: number } | null>(null);
|
||||
|
||||
|
||||
const getStepData = useCallback(async (date: Date) => {
|
||||
@@ -59,6 +73,42 @@ const StepsCard: React.FC<StepsCardProps> = ({
|
||||
}
|
||||
}, [curDate]);
|
||||
|
||||
// 步数挑战进度上报逻辑
|
||||
useEffect(() => {
|
||||
if (!curDate || !stepCount || !joinedStepsChallenges.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果当前日期不是今天,不上报
|
||||
if (!dayjs(curDate).isSame(dayjs(), 'day')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dateKey = dayjs(curDate).format('YYYY-MM-DD');
|
||||
const lastReport = lastReportedRef.current;
|
||||
|
||||
if (lastReport && lastReport.date === dateKey && lastReport.value === stepCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reportProgress = async () => {
|
||||
const stepsChallenge = joinedStepsChallenges.find((c) => c.type === ChallengeType.STEP);
|
||||
if (!stepsChallenge) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(reportChallengeProgress({ id: stepsChallenge.id, value: stepCount })).unwrap();
|
||||
} catch (error) {
|
||||
logger.warn('StepsCard: Challenge progress report failed', { error, challengeId: stepsChallenge.id });
|
||||
}
|
||||
|
||||
lastReportedRef.current = { date: dateKey, value: stepCount };
|
||||
};
|
||||
|
||||
reportProgress();
|
||||
}, [dispatch, joinedStepsChallenges, curDate, stepCount]);
|
||||
|
||||
// 优化:减少动画值数量,只为有数据的小时创建动画
|
||||
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
|
||||
|
||||
@@ -244,7 +294,8 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
footprintIcons: {
|
||||
flexDirection: 'row',
|
||||
@@ -290,6 +341,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
InteractionManager
|
||||
} from 'react-native';
|
||||
|
||||
import { fetchHourlyStepSamples, fetchStepCount, HourlyStepData } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
|
||||
interface StepsCardProps {
|
||||
curDate: Date
|
||||
stepGoal: number;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const StepsCardOptimized: React.FC<StepsCardProps> = ({
|
||||
curDate,
|
||||
style,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [stepCount, setStepCount] = useState(0)
|
||||
const [hourlySteps, setHourSteps] = useState<HourlyStepData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// 优化:使用debounce减少频繁的数据获取
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getStepData = useCallback(async (date: Date) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
logger.info('获取步数数据...');
|
||||
|
||||
// 先获取步数,立即更新UI
|
||||
const steps = await fetchStepCount(date);
|
||||
setStepCount(steps);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 使用 InteractionManager 在空闲时获取更复杂的小时数据
|
||||
InteractionManager.runAfterInteractions(async () => {
|
||||
try {
|
||||
const hourly = await fetchHourlyStepSamples(date);
|
||||
setHourSteps(hourly);
|
||||
} catch (error) {
|
||||
logger.error('获取小时步数数据失败:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取步数数据失败:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (curDate) {
|
||||
getStepData(curDate);
|
||||
}
|
||||
}, [curDate, getStepData]);
|
||||
|
||||
// 优化:减少动画值数量,只为有数据的小时创建动画
|
||||
const animatedValues = useRef<Map<number, Animated.Value>>(new Map()).current;
|
||||
|
||||
// 优化:简化柱状图数据计算,减少计算量
|
||||
const chartData = useMemo(() => {
|
||||
if (!hourlySteps || hourlySteps.length === 0) {
|
||||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
||||
}
|
||||
|
||||
// 优化:只计算有数据的小时的最大步数
|
||||
const activeSteps = hourlySteps.filter(data => data.steps > 0);
|
||||
if (activeSteps.length === 0) {
|
||||
return Array.from({ length: 24 }, (_, i) => ({ hour: i, steps: 0, height: 0 }));
|
||||
}
|
||||
|
||||
const maxSteps = Math.max(...activeSteps.map(data => data.steps));
|
||||
const maxHeight = 20;
|
||||
|
||||
return hourlySteps.map(data => ({
|
||||
...data,
|
||||
height: data.steps > 0 ? (data.steps / maxSteps) * maxHeight : 0
|
||||
}));
|
||||
}, [hourlySteps]);
|
||||
|
||||
// 获取当前小时
|
||||
const currentHour = new Date().getHours();
|
||||
|
||||
// 优化:延迟执行动画,减少UI阻塞
|
||||
useEffect(() => {
|
||||
const hasData = chartData && chartData.length > 0 && chartData.some(data => data.steps > 0);
|
||||
|
||||
if (hasData && !isLoading) {
|
||||
// 使用 InteractionManager 确保动画不会阻塞用户交互
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
// 只为有数据的小时创建和执行动画
|
||||
const animations = chartData
|
||||
.map((data, index) => {
|
||||
if (data.steps > 0) {
|
||||
// 懒创建动画值
|
||||
if (!animatedValues.has(index)) {
|
||||
animatedValues.set(index, new Animated.Value(0));
|
||||
}
|
||||
|
||||
const animValue = animatedValues.get(index)!;
|
||||
animValue.setValue(0);
|
||||
|
||||
// 使用更高性能的timing动画替代spring
|
||||
return Animated.timing(animValue, {
|
||||
toValue: 1,
|
||||
duration: 200, // 减少动画时长
|
||||
useNativeDriver: false,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as Animated.CompositeAnimation[];
|
||||
|
||||
// 批量执行动画,提高性能
|
||||
if (animations.length > 0) {
|
||||
Animated.stagger(50, animations).start();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [chartData, animatedValues, isLoading]);
|
||||
|
||||
// 优化:使用React.memo包装复杂的渲染组件
|
||||
const ChartBars = useMemo(() => {
|
||||
return chartData.map((data, index) => {
|
||||
// 判断是否是当前小时或者有活动的小时
|
||||
const isActive = data.steps > 0;
|
||||
const isCurrent = index <= currentHour;
|
||||
|
||||
// 优化:只为有数据的柱体创建动画插值
|
||||
const animValue = animatedValues.get(index);
|
||||
let animatedScale: Animated.AnimatedInterpolation<number> | undefined;
|
||||
let animatedOpacity: Animated.AnimatedInterpolation<number> | undefined;
|
||||
|
||||
if (animValue && isActive) {
|
||||
animatedScale = animValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
});
|
||||
|
||||
animatedOpacity = animValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, 1],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={`bar-container-${index}`} style={styles.barContainer}>
|
||||
{/* 背景柱体 - 始终显示,使用相似色系的淡色 */}
|
||||
<View
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{
|
||||
height: 20, // 背景柱体占满整个高度
|
||||
backgroundColor: isCurrent ? '#FFF4E6' : '#FFF8F0', // 更淡的相似色系
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 数据柱体 - 只有当有数据时才显示并执行动画 */}
|
||||
{isActive && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.chartBar,
|
||||
{
|
||||
height: data.height,
|
||||
backgroundColor: isCurrent ? '#FFC365' : '#FFEBCB',
|
||||
transform: animatedScale ? [{ scaleY: animatedScale }] : undefined,
|
||||
opacity: animatedOpacity || 1,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}, [chartData, currentHour, animatedValues]);
|
||||
|
||||
const CardContent = () => (
|
||||
<>
|
||||
{/* 标题和步数显示 */}
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={require('@/assets/images/icons/icon-step.png')}
|
||||
style={styles.titleIcon}
|
||||
/>
|
||||
<Text style={styles.title}>步数</Text>
|
||||
{isLoading && <Text style={styles.loadingText}>加载中...</Text>}
|
||||
</View>
|
||||
|
||||
{/* 柱状图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.chartWrapper}>
|
||||
<View style={styles.chartArea}>
|
||||
{ChartBars}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 步数和目标显示 */}
|
||||
<View style={styles.statsContainer}>
|
||||
<AnimatedNumber
|
||||
value={stepCount || 0}
|
||||
style={styles.stepCount}
|
||||
format={(v) => stepCount !== null ? `${Math.round(v)}` : '——'}
|
||||
resetToken={stepCount}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, style]}
|
||||
onPress={() => {
|
||||
// 传递当前日期参数到详情页
|
||||
const dateParam = dayjs(curDate).format('YYYY-MM-DD');
|
||||
router.push(`/steps/detail?date=${dateParam}`);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<CardContent />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: 20,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 4,
|
||||
},
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
},
|
||||
titleIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 6,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 10,
|
||||
color: '#666',
|
||||
marginLeft: 8,
|
||||
},
|
||||
chartContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
marginTop: 6
|
||||
},
|
||||
chartWrapper: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
chartArea: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
height: 20,
|
||||
width: '100%',
|
||||
maxWidth: 240,
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
barContainer: {
|
||||
width: 4,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
position: 'relative',
|
||||
},
|
||||
chartBar: {
|
||||
width: 4,
|
||||
borderRadius: 1,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
},
|
||||
statsContainer: {
|
||||
alignItems: 'flex-start',
|
||||
marginTop: 6
|
||||
},
|
||||
stepCount: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
},
|
||||
});
|
||||
|
||||
export default StepsCardOptimized;
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { fetchHRVSamples, HRVData } from '@/utils/health';
|
||||
import { convertHrvToStressIndex, getStressLevelInfo } from '@/utils/stress';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -18,18 +24,103 @@ interface StressAnalysisModalProps {
|
||||
updateTime: Date;
|
||||
}
|
||||
|
||||
interface StressStats {
|
||||
percentage: number;
|
||||
count: number;
|
||||
range: string;
|
||||
}
|
||||
|
||||
interface HistoryData {
|
||||
goodEvents: StressStats;
|
||||
energetic: StressStats;
|
||||
stressed: StressStats;
|
||||
totalSamples: number;
|
||||
}
|
||||
|
||||
export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }: StressAnalysisModalProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = Colors[colorScheme ?? 'light'];
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [historyData, setHistoryData] = useState<HistoryData>({
|
||||
goodEvents: { percentage: 0, count: 0, range: '>75毫秒' },
|
||||
energetic: { percentage: 0, count: 0, range: '40-75毫秒' },
|
||||
stressed: { percentage: 0, count: 0, range: '<40毫秒' },
|
||||
totalSamples: 0
|
||||
});
|
||||
|
||||
// 模拟30天HRV数据
|
||||
const hrvData = {
|
||||
goodEvents: { percentage: 26, count: 53, range: '>80毫秒' },
|
||||
energetic: { percentage: 47, count: 97, range: '43-80毫秒' },
|
||||
stressed: { percentage: 27, count: 56, range: '<43毫秒' },
|
||||
// 当前压力状态
|
||||
const stressIndex = convertHrvToStressIndex(hrvValue);
|
||||
const stressInfo = getStressLevelInfo(stressIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadHistoryData();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const loadHistoryData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const endDate = new Date();
|
||||
const startDate = dayjs().subtract(30, 'day').toDate();
|
||||
|
||||
const samples = await fetchHRVSamples(startDate, endDate);
|
||||
processHistoryData(samples);
|
||||
} catch (error) {
|
||||
console.error('Failed to load HRV history:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const processHistoryData = (samples: HRVData[]) => {
|
||||
if (!samples.length) return;
|
||||
|
||||
let goodCount = 0;
|
||||
let energeticCount = 0;
|
||||
let stressedCount = 0;
|
||||
|
||||
samples.forEach(sample => {
|
||||
const val = sample.value;
|
||||
if (val > 75) {
|
||||
goodCount++;
|
||||
} else if (val >= 40) {
|
||||
energeticCount++;
|
||||
} else {
|
||||
stressedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const total = samples.length;
|
||||
|
||||
setHistoryData({
|
||||
goodEvents: {
|
||||
percentage: Math.round((goodCount / total) * 100),
|
||||
count: goodCount,
|
||||
range: '>75毫秒'
|
||||
},
|
||||
energetic: {
|
||||
percentage: Math.round((energeticCount / total) * 100),
|
||||
count: energeticCount,
|
||||
range: '40-75毫秒'
|
||||
},
|
||||
stressed: {
|
||||
percentage: Math.round((stressedCount / total) * 100),
|
||||
count: stressedCount,
|
||||
range: '<40毫秒'
|
||||
},
|
||||
totalSamples: total
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'low': return '#10B981';
|
||||
case 'moderate': return '#3B82F6';
|
||||
case 'high': return '#F59E0B';
|
||||
default: return colors.text;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -45,80 +136,139 @@ export function StressAnalysisModal({ visible, onClose, hrvValue, updateTime }:
|
||||
end={{ x: 0, y: 1 }}
|
||||
>
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* 标题 */}
|
||||
<Text style={styles.title}>压力情况分析</Text>
|
||||
{/* 标题区域 */}
|
||||
<Text style={styles.title}>压力分析</Text>
|
||||
|
||||
{/* 当前状态卡片 */}
|
||||
<View style={styles.currentStatusCard}>
|
||||
<View style={styles.statusHeader}>
|
||||
<Text style={styles.statusLabel}>当前状态</Text>
|
||||
<Text style={styles.updateTime}>更新于 {dayjs(updateTime).format('HH:mm')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusValueContainer}>
|
||||
<View>
|
||||
<Text style={[styles.statusText, { color: getStatusColor(stressInfo.level) }]}>
|
||||
{stressInfo.label}
|
||||
</Text>
|
||||
<Text style={styles.statusDesc}>{stressInfo.description}</Text>
|
||||
</View>
|
||||
<View style={styles.hrvValueBox}>
|
||||
<Text style={styles.hrvValueLabel}>HRV</Text>
|
||||
<Text style={styles.hrvValue}>{Math.round(hrvValue)}<Text style={styles.hrvUnit}>ms</Text></Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* 最近30天HRV情况 */}
|
||||
<Text style={styles.sectionTitle}>最近30天HRV情况</Text>
|
||||
<Text style={styles.sectionTitle}>最近30天压力分布</Text>
|
||||
|
||||
{/* 彩色横条图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.colorBar}>
|
||||
<LinearGradient
|
||||
colors={['#F59E0B', '#3B82F6', '#10B981']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientBar}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||
<Text style={styles.legendText}>鸭梨山大</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
||||
<Text style={styles.legendText}>活力满满</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
||||
<Text style={styles.legendText}>好事发生</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 数据统计卡片 */}
|
||||
<View style={styles.statsCard}>
|
||||
{/* 好事发生 & 活力满满 */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.goodEvents.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.goodEvents.range}</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={colors.primary} style={{ marginTop: 20 }} />
|
||||
) : (
|
||||
<>
|
||||
{/* 彩色横条图 */}
|
||||
<View style={styles.chartContainer}>
|
||||
<View style={styles.colorBar}>
|
||||
{historyData.totalSamples > 0 ? (
|
||||
<View style={styles.progressBarContainer}>
|
||||
{historyData.stressed.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.stressed.percentage, backgroundColor: '#F59E0B', marginRight: 2 }]} />
|
||||
)}
|
||||
{historyData.energetic.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.energetic.percentage, backgroundColor: '#3B82F6', marginRight: 2 }]} />
|
||||
)}
|
||||
{historyData.goodEvents.percentage > 0 && (
|
||||
<View style={[styles.progressSegment, { flex: historyData.goodEvents.percentage, backgroundColor: '#10B981' }]} />
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.progressBarContainer, { backgroundColor: '#E5E7EB' }]} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.goodEvents.count}次</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.energetic.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.energetic.range}</Text>
|
||||
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#F59E0B' }]} />
|
||||
<Text style={styles.legendText}>鸭梨山大</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#3B82F6' }]} />
|
||||
<Text style={styles.legendText}>活力满满</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: '#10B981' }]} />
|
||||
<Text style={styles.legendText}>好事发生</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.energetic.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 鸭梨山大 */}
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
||||
<Text style={styles.statPercentage}>{hrvData.stressed.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={styles.statRange}>❤️ {hrvData.stressed.range}</Text>
|
||||
{/* 数据统计卡片 */}
|
||||
<View style={styles.statsCard}>
|
||||
{/* 好事发生 & 活力满满 */}
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#10B981' }]}>好事发生</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.goodEvents.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={[styles.statRange, { color: '#10B981', backgroundColor: '#ECFDF5' }]}>
|
||||
HRV {historyData.goodEvents.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{historyData.goodEvents.count}次</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#3B82F6' }]}>活力满满</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.energetic.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={[styles.statRange, { color: '#3B82F6', backgroundColor: '#EFF6FF' }]}>
|
||||
HRV {historyData.energetic.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{historyData.energetic.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 鸭梨山大 */}
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statTitle, { color: '#F59E0B' }]}>鸭梨山大</Text>
|
||||
<Text style={styles.statPercentage}>{historyData.stressed.percentage}%</Text>
|
||||
<View style={styles.statDetails}>
|
||||
<Text style={[styles.statRange, { color: '#F59E0B', backgroundColor: '#FFFBEB' }]}>
|
||||
HRV {historyData.stressed.range}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{historyData.stressed.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.statCount}>{hrvData.stressed.count}次</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部继续按钮 */}
|
||||
<View style={styles.bottomContainer}>
|
||||
<TouchableOpacity style={styles.continueButton} onPress={onClose}>
|
||||
<View style={styles.buttonBackground}>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.continueButton} onPress={onClose} activeOpacity={0.85}>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(139, 92, 246, 0.85)"
|
||||
isInteractive={true}
|
||||
style={styles.glassButton}
|
||||
>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</GlassView>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={['#8B5CF6', '#7C3AED']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.buttonGradient}
|
||||
>
|
||||
<Text style={styles.buttonText}>继续</Text>
|
||||
</LinearGradient>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View style={styles.homeIndicator} />
|
||||
</View>
|
||||
@@ -140,15 +290,78 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
marginTop: 20,
|
||||
marginTop: 24,
|
||||
marginBottom: 32,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
|
||||
sectionTitle: {
|
||||
fontSize: 22,
|
||||
currentStatusCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
statusHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#374151',
|
||||
},
|
||||
updateTime: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
},
|
||||
statusValueContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDesc: {
|
||||
fontSize: 14,
|
||||
color: '#6B7280',
|
||||
maxWidth: 200,
|
||||
},
|
||||
hrvValueBox: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
hrvValueLabel: {
|
||||
fontSize: 12,
|
||||
color: '#9CA3AF',
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
hrvValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
lineHeight: 36,
|
||||
},
|
||||
hrvUnit: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6B7280',
|
||||
marginLeft: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
marginBottom: 20,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
chartContainer: {
|
||||
marginBottom: 32,
|
||||
@@ -158,6 +371,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
progressBarContainer: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
progressSegment: {
|
||||
height: '100%',
|
||||
},
|
||||
gradientBar: {
|
||||
flex: 1,
|
||||
@@ -171,96 +393,102 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
},
|
||||
legendDot: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
marginRight: 6,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
marginRight: 8,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#4B5563',
|
||||
},
|
||||
statsCard: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
borderRadius: 20,
|
||||
padding: 24,
|
||||
marginBottom: 32,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 12,
|
||||
elevation: 3,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
marginBottom: 24,
|
||||
gap: 24,
|
||||
marginBottom: 32,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
},
|
||||
statTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
statPercentage: {
|
||||
fontSize: 36,
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
statDetails: {
|
||||
marginBottom: 4,
|
||||
marginBottom: 8,
|
||||
},
|
||||
statRange: {
|
||||
fontSize: 14,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#DC2626',
|
||||
backgroundColor: '#FEE2E2',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
alignSelf: 'flex-start',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
statCount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
},
|
||||
bottomContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 34,
|
||||
paddingBottom: Platform.OS === 'ios' ? 34 : 20,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
continueButton: {
|
||||
borderRadius: 25,
|
||||
borderRadius: 28,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#8B5CF6',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 16,
|
||||
elevation: 8,
|
||||
},
|
||||
glassButton: {
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
borderRadius: 28,
|
||||
},
|
||||
buttonGradient: {
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
buttonBackground: {
|
||||
backgroundColor: Colors.light.accentGreen, // 应用主色调
|
||||
paddingVertical: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#192126', // 主色调上的文字颜色
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
homeIndicator: {
|
||||
width: 134,
|
||||
height: 5,
|
||||
backgroundColor: '#000',
|
||||
backgroundColor: Platform.OS === 'ios' ? 'rgba(0, 0, 0, 0.3)' : '#000',
|
||||
borderRadius: 3,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hrvValue, setHrvValue] = useState(0)
|
||||
const [updateTime, setUpdateTime] = useState<Date>(new Date())
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,6 +33,9 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
|
||||
if (result.hrvData) {
|
||||
setHrvValue(Math.round(result.hrvData.value));
|
||||
if (result.hrvData.recordedAt) {
|
||||
setUpdateTime(new Date(result.hrvData.recordedAt));
|
||||
}
|
||||
console.log(`StressMeter: Using ${result.message}, HRV value: ${result.hrvData.value}ms`);
|
||||
} else {
|
||||
console.log('StressMeter: No HRV data obtained');
|
||||
@@ -92,7 +96,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
{/* 渐变背景进度条 */}
|
||||
<View style={[styles.progressBar, { width: '100%' }]}>
|
||||
<LinearGradient
|
||||
colors={['#EF4444', '#FCD34D', '#10B981']}
|
||||
colors={['#10B981', '#FCD34D', '#EF4444']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.gradientBar}
|
||||
@@ -110,7 +114,7 @@ export function StressMeter({ curDate }: StressMeterProps) {
|
||||
visible={showStressModal}
|
||||
onClose={() => setShowStressModal(false)}
|
||||
hrvValue={hrvValue}
|
||||
updateTime={new Date()}
|
||||
updateTime={updateTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -158,7 +162,8 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600'
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
valueSection: {
|
||||
flexDirection: 'row',
|
||||
@@ -171,12 +176,14 @@ const styles = StyleSheet.create({
|
||||
color: '#192126',
|
||||
lineHeight: 20,
|
||||
marginTop: 2,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
unit: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#9AA3AE',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
progressContainer: {
|
||||
height: 6,
|
||||
|
||||
343
components/VersionUpdateModal.tsx
Normal file
343
components/VersionUpdateModal.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import type { VersionInfo } from '@/services/version';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
type VersionUpdateModalProps = {
|
||||
visible: boolean;
|
||||
info: VersionInfo | null;
|
||||
currentVersion: string;
|
||||
onClose: () => void;
|
||||
onUpdate: () => void;
|
||||
strings: {
|
||||
title: string;
|
||||
tag: string;
|
||||
currentVersionLabel: string;
|
||||
latestVersionLabel: string;
|
||||
updatesTitle: string;
|
||||
fallbackNote: string;
|
||||
remindLater: string;
|
||||
updateCta: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function VersionUpdateModal({
|
||||
visible,
|
||||
info,
|
||||
currentVersion,
|
||||
onClose,
|
||||
onUpdate,
|
||||
strings,
|
||||
}: VersionUpdateModalProps) {
|
||||
const notes = useMemo(() => {
|
||||
if (!info) return [];
|
||||
|
||||
if (info.releaseNotes && info.releaseNotes.trim().length > 0) {
|
||||
return info.releaseNotes
|
||||
.split(/\r?\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (info.updateMessage && info.updateMessage.trim().length > 0) {
|
||||
return [info.updateMessage.trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [info]);
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
|
||||
<View style={styles.cardShadow}>
|
||||
<LinearGradient
|
||||
colors={['#0F1B61', '#0F274A', '#0A1A3A']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.card}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.18)', 'rgba(255,255,255,0.03)']}
|
||||
style={styles.glowOrb}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(255,255,255,0.08)', 'transparent']}
|
||||
style={styles.ribbon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.tag}>
|
||||
<Ionicons name="sparkles" size={14} color="#0F1B61" />
|
||||
<Text style={styles.tagText}>{strings.tag}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={18} color="#E5E7EB" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title}>{strings.title}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{info.latestVersion ? `v${info.latestVersion}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<View style={styles.metaChip}>
|
||||
<Ionicons name="time-outline" size={14} color="#C7D2FE" />
|
||||
<Text style={styles.metaText}>
|
||||
{strings.currentVersionLabel} v{currentVersion}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaChip}>
|
||||
<Ionicons name="arrow-up-circle-outline" size={14} color="#C7D2FE" />
|
||||
<Text style={styles.metaText}>
|
||||
{strings.latestVersionLabel} v{info.latestVersion}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.noteCard}>
|
||||
<Text style={styles.noteTitle}>{strings.updatesTitle}</Text>
|
||||
{notes.length > 0 ? (
|
||||
notes.map((line, idx) => (
|
||||
<View key={`${idx}-${line}`} style={styles.noteItem}>
|
||||
<View style={styles.bullet}>
|
||||
<Ionicons name="ellipse" size={6} color="#6EE7B7" />
|
||||
</View>
|
||||
<Text style={styles.noteText}>{line}</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.noteText}>{strings.fallbackNote}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={onClose}
|
||||
style={styles.secondaryButton}
|
||||
>
|
||||
<Text style={styles.secondaryText}>{strings.remindLater}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onUpdate}
|
||||
style={styles.primaryButtonShadow}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#6EE7B7', '#3B82F6']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.primaryButton}
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={18} color="#0B1236" />
|
||||
<Text style={styles.primaryText}>{strings.updateCta}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(7, 11, 34, 0.65)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
cardShadow: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
shadowColor: '#0B1236',
|
||||
shadowOpacity: 0.35,
|
||||
shadowOffset: { width: 0, height: 16 },
|
||||
shadowRadius: 30,
|
||||
elevation: 8,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
glowOrb: {
|
||||
position: 'absolute',
|
||||
width: 220,
|
||||
height: 220,
|
||||
borderRadius: 110,
|
||||
right: -60,
|
||||
top: -80,
|
||||
opacity: 0.8,
|
||||
},
|
||||
ribbon: {
|
||||
position: 'absolute',
|
||||
left: -120,
|
||||
bottom: -120,
|
||||
width: 260,
|
||||
height: 260,
|
||||
transform: [{ rotate: '-8deg' }],
|
||||
opacity: 0.6,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#A5B4FC',
|
||||
},
|
||||
tagText: {
|
||||
color: '#0F1B61',
|
||||
fontWeight: '700',
|
||||
marginLeft: 6,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
closeButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
titleBlock: {
|
||||
marginTop: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '800',
|
||||
color: '#F9FAFB',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
subtitle: {
|
||||
color: '#C7D2FE',
|
||||
marginTop: 6,
|
||||
fontSize: 15,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: 10,
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
metaChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
metaText: {
|
||||
color: '#E5E7EB',
|
||||
marginLeft: 6,
|
||||
fontSize: 12,
|
||||
},
|
||||
noteCard: {
|
||||
marginTop: 16,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
backgroundColor: 'rgba(255,255,255,0.06)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.04)',
|
||||
},
|
||||
noteTitle: {
|
||||
color: '#F9FAFB',
|
||||
fontWeight: '700',
|
||||
fontSize: 15,
|
||||
marginBottom: 8,
|
||||
},
|
||||
noteItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginTop: 8,
|
||||
},
|
||||
bullet: {
|
||||
width: 18,
|
||||
alignItems: 'center',
|
||||
marginTop: 6,
|
||||
},
|
||||
noteText: {
|
||||
flex: 1,
|
||||
color: '#E5E7EB',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
actions: {
|
||||
marginTop: 18,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.16)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
secondaryText: {
|
||||
color: '#E5E7EB',
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
},
|
||||
primaryButtonShadow: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#1E40AF',
|
||||
shadowOpacity: 0.4,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
shadowRadius: 14,
|
||||
elevation: 6,
|
||||
},
|
||||
primaryButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
primaryText: {
|
||||
color: '#0B1236',
|
||||
fontWeight: '800',
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
||||
export default VersionUpdateModal;
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
// 处理卡片点击 - 跳转到饮水详情页面
|
||||
@@ -305,6 +309,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
addButton: {
|
||||
borderRadius: 16,
|
||||
@@ -319,6 +324,7 @@ const styles = StyleSheet.create({
|
||||
color: '#6366F1',
|
||||
fontWeight: '700',
|
||||
lineHeight: 10,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
chartContainer: {
|
||||
flex: 1,
|
||||
@@ -359,11 +365,13 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
targetIntake: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
marginLeft: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -269,6 +269,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
color: '#1F2355',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
addButton: {
|
||||
width: 28,
|
||||
@@ -287,6 +288,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 20,
|
||||
color: '#7A8FFF',
|
||||
marginTop: -2,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metricsRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -310,12 +312,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#1F2355',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 12,
|
||||
color: '#4A5677',
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
detailsRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -331,14 +335,17 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
color: '#1F2355',
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
lastWorkoutTime: {
|
||||
fontSize: 12,
|
||||
color: '#7C85A3',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
sourceText: {
|
||||
fontSize: 11,
|
||||
color: '#9AA3C0',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
badgesRow: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -215,11 +215,13 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
remaining: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
alignSelf: 'flex-start',
|
||||
fontFamily: 'AliRegular'
|
||||
},
|
||||
metaRow: {
|
||||
marginTop: 12,
|
||||
@@ -227,10 +229,12 @@ const styles = StyleSheet.create({
|
||||
metaValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
metaSuffix: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliBold'
|
||||
},
|
||||
track: {
|
||||
marginTop: 12,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import type { RankingItem } from '@/store/challengesSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
@@ -18,34 +19,34 @@ const formatNumber = (value: number): string => {
|
||||
return value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
|
||||
};
|
||||
|
||||
const formatMinutes = (value: number): string => {
|
||||
const safeValue = Math.max(0, Math.round(value));
|
||||
const hours = safeValue / 60;
|
||||
return `${hours.toFixed(1)} 小时`;
|
||||
};
|
||||
|
||||
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (unit === 'min') {
|
||||
return formatMinutes(value);
|
||||
}
|
||||
const formatted = formatNumber(value);
|
||||
return unit ? `${formatted} ${unit}` : formatted;
|
||||
};
|
||||
|
||||
export function ChallengeRankingItem({ item, index, showDivider = false, unit }: ChallengeRankingItemProps) {
|
||||
console.log('unit', unit);
|
||||
const { t } = useI18n();
|
||||
|
||||
const formatMinutes = (value: number): string => {
|
||||
const safeValue = Math.max(0, Math.round(value));
|
||||
const hours = safeValue / 60;
|
||||
return `${hours.toFixed(1)} ${t('challengeDetail.ranking.hour')}`;
|
||||
};
|
||||
|
||||
const formatValueWithUnit = (value: number | undefined, unit?: string): string | undefined => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (unit === 'min') {
|
||||
return formatMinutes(value);
|
||||
}
|
||||
const formatted = formatNumber(value);
|
||||
return unit ? `${formatted} ${unit}` : formatted;
|
||||
};
|
||||
|
||||
const reportedLabel = formatValueWithUnit(item.todayReportedValue, unit);
|
||||
const targetLabel = formatValueWithUnit(item.todayTargetValue, unit);
|
||||
const progressLabel = reportedLabel && targetLabel
|
||||
? `今日 ${reportedLabel} / ${targetLabel}`
|
||||
? `${t('challengeDetail.ranking.today')} ${reportedLabel} / ${targetLabel}`
|
||||
: reportedLabel
|
||||
? `今日 ${reportedLabel}`
|
||||
? `${t('challengeDetail.ranking.today')} ${reportedLabel}`
|
||||
: targetLabel
|
||||
? `今日目标 ${targetLabel}`
|
||||
? `${t('challengeDetail.ranking.todayGoal')} ${targetLabel}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
|
||||
132
components/health/HealthProgressRing.tsx
Normal file
132
components/health/HealthProgressRing.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Animated, Easing, StyleSheet, Text, View } from 'react-native';
|
||||
import Svg, { Circle, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
export type HealthProgressRingProps = {
|
||||
progress: number; // 0-100
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
gradientColors?: string[];
|
||||
label?: string;
|
||||
suffix?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function HealthProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 8,
|
||||
gradientColors = ['#5B4CFF', '#9B8AFB'],
|
||||
label,
|
||||
suffix = '%',
|
||||
title,
|
||||
}: HealthProgressRingProps) {
|
||||
const animatedProgress = useRef(new Animated.Value(0)).current;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const center = size / 2;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(animatedProgress, {
|
||||
toValue: progress,
|
||||
duration: 1000,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [progress]);
|
||||
|
||||
const strokeDashoffset = animatedProgress.interpolate({
|
||||
inputRange: [0, 100],
|
||||
outputRange: [circumference, 0],
|
||||
extrapolate: 'clamp',
|
||||
});
|
||||
|
||||
const gradientId = useRef(`grad-${Math.random().toString(36).substr(2, 9)}`).current;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Svg width={size} height={size}>
|
||||
<Defs>
|
||||
<LinearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<Stop offset="0" stopColor={gradientColors[0]} stopOpacity="1" />
|
||||
<Stop offset="1" stopColor={gradientColors[1]} stopOpacity="1" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
|
||||
{/* Background Circle */}
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke="#F3F4F6"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Progress Circle */}
|
||||
<AnimatedCircle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
<View style={styles.valueContainer}>
|
||||
<Text style={styles.valueText}>{label ?? progress}</Text>
|
||||
<Text style={styles.suffixText}>{suffix}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.titleText}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
centerContent: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
valueContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
valueText: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
fontFamily: 'AliBold',
|
||||
lineHeight: 24,
|
||||
},
|
||||
suffixText: {
|
||||
fontSize: 12,
|
||||
color: '#6B7280',
|
||||
fontWeight: '500',
|
||||
marginLeft: 1,
|
||||
marginBottom: 3,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
titleText: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
color: '#4B5563', // gray-600
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
151
components/health/MedicalRecordCard.tsx
Normal file
151
components/health/MedicalRecordCard.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Colors, palette } from '@/constants/Colors';
|
||||
import { MedicalRecordItem } from '@/services/healthProfile';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { Image } from 'expo-image';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface MedicalRecordCardProps {
|
||||
item: MedicalRecordItem;
|
||||
onPress: (item: MedicalRecordItem) => void;
|
||||
onDelete: (item: MedicalRecordItem) => void;
|
||||
}
|
||||
|
||||
export const MedicalRecordCard: React.FC<MedicalRecordCardProps> = ({ item, onPress, onDelete }) => {
|
||||
const firstAttachment = item.images && item.images.length > 0 ? item.images[0] : null;
|
||||
const isPdf = firstAttachment?.toLowerCase().endsWith('.pdf');
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={() => onPress(item)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.thumbnailContainer}>
|
||||
{firstAttachment ? (
|
||||
isPdf ? (
|
||||
<View style={styles.pdfThumbnail}>
|
||||
<Ionicons name="document-text" size={32} color="#EF4444" />
|
||||
<Text style={styles.pdfText}>PDF</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri: firstAttachment }}
|
||||
style={styles.thumbnail}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<View style={styles.placeholderThumbnail}>
|
||||
<Ionicons name="document-text-outline" size={32} color={palette.gray[300]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{item.images && item.images.length > 1 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>+{item.images.length - 1}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
|
||||
<Text style={styles.date}>{dayjs(item.date).format('YYYY-MM-DD')}</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => onDelete(item)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color={palette.gray[400]} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: palette.gray[200],
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
flexDirection: 'row',
|
||||
height: 100,
|
||||
},
|
||||
thumbnailContainer: {
|
||||
width: 100,
|
||||
height: '100%',
|
||||
backgroundColor: palette.gray[50],
|
||||
position: 'relative',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
pdfThumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
pdfText: {
|
||||
fontSize: 10,
|
||||
marginTop: 4,
|
||||
color: '#EF4444',
|
||||
fontWeight: '600',
|
||||
},
|
||||
placeholderThumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: palette.gray[50],
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
badgeText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: palette.gray[800],
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
date: {
|
||||
fontSize: 12,
|
||||
color: palette.purple[600],
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
deleteButton: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
padding: 4,
|
||||
},
|
||||
});
|
||||
161
components/health/tabs/BasicInfoTab.tsx
Normal file
161
components/health/tabs/BasicInfoTab.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
type BasicInfoTabProps = {
|
||||
healthData: {
|
||||
bmi: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
waist: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function BasicInfoTab({ healthData }: BasicInfoTabProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const handleHeightWeightPress = () => {
|
||||
router.push(ROUTES.PROFILE_EDIT);
|
||||
};
|
||||
|
||||
const handleWaistPress = () => {
|
||||
router.push('/circumference-detail');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>{t('health.tabs.healthProfile.basicInfoCard.title')}</Text>
|
||||
<View style={styles.metricsGrid}>
|
||||
{/* BMI - Highlighted */}
|
||||
<View style={styles.metricItemMain}>
|
||||
<Text style={styles.metricLabelMain}>{t('health.tabs.healthProfile.basicInfoCard.bmi')}</Text>
|
||||
<Text style={styles.metricValueMain}>
|
||||
{healthData.bmi === '--' ? t('health.tabs.healthProfile.basicInfoCard.noData') : healthData.bmi}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Height - Clickable */}
|
||||
<TouchableOpacity
|
||||
style={styles.metricItem}
|
||||
onPress={handleHeightWeightPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.metricHeaderSmall}>
|
||||
<Text style={styles.metricValue}>{healthData.height}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.metricLabel}>
|
||||
{t('health.tabs.healthProfile.basicInfoCard.height')}/{t('health.tabs.healthProfile.basicInfoCard.heightUnit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Weight - Clickable */}
|
||||
<TouchableOpacity
|
||||
style={styles.metricItem}
|
||||
onPress={handleHeightWeightPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.metricHeaderSmall}>
|
||||
<Text style={styles.metricValue}>{healthData.weight}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.metricLabel}>
|
||||
{t('health.tabs.healthProfile.basicInfoCard.weight')}/{t('health.tabs.healthProfile.basicInfoCard.weightUnit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Waist - Clickable */}
|
||||
<TouchableOpacity
|
||||
style={styles.metricItem}
|
||||
onPress={handleWaistPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.metricHeaderSmall}>
|
||||
<Text style={styles.metricValue}>{healthData.waist}</Text>
|
||||
<Ionicons name="chevron-forward" size={12} color="#9CA3AF" />
|
||||
</View>
|
||||
<Text style={styles.metricLabel}>
|
||||
{t('health.tabs.healthProfile.basicInfoCard.waist')}/{t('health.tabs.healthProfile.basicInfoCard.waistUnit')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
elevation: 1,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#1F2937',
|
||||
marginBottom: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metricsGrid: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
metricItemMain: {
|
||||
flex: 1.5,
|
||||
backgroundColor: '#F5F3FF',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginRight: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
metricHeader: {
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
marginBottom: 8,
|
||||
},
|
||||
metricLabelMain: {
|
||||
fontSize: 14,
|
||||
color: '#5B4CFF',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
metricValueMain: {
|
||||
fontSize: 16,
|
||||
color: '#5B4CFF',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
metricItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
metricHeaderSmall: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
gap: 2,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
color: '#6B7280',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
color: '#1F2937',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
49
components/health/tabs/CheckupRecordsTab.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export function CheckupRecordsTab() {
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="clipboard-outline" size={48} color="#E5E7EB" />
|
||||
<Text style={styles.emptyText}>暂无体检记录</Text>
|
||||
<Text style={styles.emptySubtext}>记录并追踪您的体检数据变化</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 20,
|
||||
padding: 40,
|
||||
marginBottom: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.03,
|
||||
shadowRadius: 6,
|
||||
elevation: 1,
|
||||
minHeight: 200,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtext: {
|
||||
marginTop: 8,
|
||||
fontSize: 13,
|
||||
color: '#9CA3AF',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
788
components/health/tabs/HealthHistoryTab.tsx
Normal file
788
components/health/tabs/HealthHistoryTab.tsx
Normal file
@@ -0,0 +1,788 @@
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { HealthHistoryCategory } from '@/services/healthProfile';
|
||||
import {
|
||||
HistoryItemDetail,
|
||||
fetchHealthHistory,
|
||||
saveHealthHistoryCategory,
|
||||
selectHealthLoading,
|
||||
selectHistoryData,
|
||||
updateHistoryData,
|
||||
} from '@/store/healthSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import { palette } from '../../../constants/Colors';
|
||||
|
||||
// Translation Keys for Recommendations
|
||||
const RECOMMENDATION_KEYS: Record<string, string[]> = {
|
||||
allergy: ['penicillin', 'sulfonamides', 'peanuts', 'seafood', 'pollen', 'dustMites', 'alcohol', 'mango'],
|
||||
disease: ['hypertension', 'diabetes', 'asthma', 'heartDisease', 'gastritis', 'migraine'],
|
||||
surgery: ['appendectomy', 'cesareanSection', 'tonsillectomy', 'fractureRepair', 'none'],
|
||||
familyDisease: ['hypertension', 'diabetes', 'cancer', 'heartDisease', 'stroke', 'alzheimers'],
|
||||
};
|
||||
|
||||
interface HistoryItemProps {
|
||||
title: string;
|
||||
categoryKey: string;
|
||||
data: {
|
||||
hasHistory: boolean | null;
|
||||
items: HistoryItemDetail[];
|
||||
};
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
function HistoryItem({ title, categoryKey, data, onPress }: HistoryItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const translateItemName = (name: string) => {
|
||||
const keys = RECOMMENDATION_KEYS[categoryKey];
|
||||
if (keys && keys.includes(name)) {
|
||||
return t(`health.tabs.healthProfile.history.recommendationItems.${categoryKey}.${name}`);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
const hasItems = data.hasHistory === true && data.items.length > 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, hasItems && styles.itemContainerWithList]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Header Row */}
|
||||
<View style={styles.itemHeader}>
|
||||
<View style={styles.itemLeft}>
|
||||
<LinearGradient
|
||||
colors={[palette.purple[400], palette.purple[600]]}
|
||||
style={styles.indicator}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.itemTitle}>{title}</Text>
|
||||
</View>
|
||||
|
||||
{!hasItems && (
|
||||
<Text style={[
|
||||
styles.itemStatus,
|
||||
(data.hasHistory === true && data.items.length === 0) || data.hasHistory === false ? styles.itemStatusActive : null
|
||||
]}>
|
||||
{data.hasHistory === null
|
||||
? t('health.tabs.healthProfile.history.pending')
|
||||
: data.hasHistory === false
|
||||
? t('health.tabs.healthProfile.history.modal.none')
|
||||
: t('health.tabs.healthProfile.history.modal.yesNoDetails')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* List of Items */}
|
||||
{hasItems && (
|
||||
<View style={styles.subListContainer}>
|
||||
{data.items.map(item => (
|
||||
<View key={item.id} style={styles.subItemRow}>
|
||||
<View style={styles.subItemDot} />
|
||||
<Text style={styles.subItemName}>{translateItemName(item.name)}</Text>
|
||||
{item.date && (
|
||||
<Text style={styles.subItemDate}>
|
||||
{dayjs(item.date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthHistoryTab() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// 从 Redux store 获取健康史数据和加载状态
|
||||
const historyData = useAppSelector(selectHistoryData);
|
||||
const isLoading = useAppSelector(selectHealthLoading);
|
||||
|
||||
// Modal State
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentType, setCurrentType] = useState<string | null>(null);
|
||||
const [tempHasHistory, setTempHasHistory] = useState<boolean | null>(null);
|
||||
const [tempItems, setTempItems] = useState<HistoryItemDetail[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Date Picker State
|
||||
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||
const [currentEditingId, setCurrentEditingId] = useState<string | null>(null);
|
||||
|
||||
// 初始化时从服务端获取健康史数据(如果父组件未加载)
|
||||
useEffect(() => {
|
||||
// 只在数据为空时才主动拉取,避免重复请求
|
||||
if (!historyData || Object.keys(historyData).length === 0) {
|
||||
dispatch(fetchHealthHistory());
|
||||
}
|
||||
}, [dispatch, historyData]);
|
||||
|
||||
const historyItems = [
|
||||
{ title: t('health.tabs.healthProfile.history.allergy'), key: 'allergy' },
|
||||
{ title: t('health.tabs.healthProfile.history.disease'), key: 'disease' },
|
||||
{ title: t('health.tabs.healthProfile.history.surgery'), key: 'surgery' },
|
||||
{ title: t('health.tabs.healthProfile.history.familyDisease'), key: 'familyDisease' },
|
||||
];
|
||||
|
||||
// Helper to translate item (try to find key, fallback to item itself)
|
||||
const translateItem = (type: string, item: string) => {
|
||||
// Check if item is a predefined key
|
||||
const keys = RECOMMENDATION_KEYS[type];
|
||||
if (keys && keys.includes(item)) {
|
||||
return t(`health.tabs.healthProfile.history.recommendationItems.${type}.${item}`);
|
||||
}
|
||||
// Fallback for manual input
|
||||
return item;
|
||||
};
|
||||
|
||||
// Open Modal
|
||||
const handleItemPress = (key: string) => {
|
||||
setCurrentType(key);
|
||||
const currentData = historyData[key];
|
||||
setTempHasHistory(currentData.hasHistory);
|
||||
// Deep copy items to avoid reference issues
|
||||
setTempItems(currentData.items.map(item => ({ ...item })));
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Close Modal
|
||||
const handleCloseModal = () => {
|
||||
setModalVisible(false);
|
||||
setCurrentType(null);
|
||||
};
|
||||
|
||||
// Save Data
|
||||
const handleSave = async () => {
|
||||
if (currentType) {
|
||||
// Filter out empty items
|
||||
const validItems = tempItems.filter(item => item.name.trim() !== '');
|
||||
|
||||
// If "No" history is selected, clear items
|
||||
const finalItems = tempHasHistory === false ? [] : validItems;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// 先乐观更新本地状态
|
||||
dispatch(updateHistoryData({
|
||||
type: currentType,
|
||||
data: {
|
||||
hasHistory: tempHasHistory,
|
||||
items: finalItems,
|
||||
},
|
||||
}));
|
||||
|
||||
// 同步到服务端
|
||||
await dispatch(saveHealthHistoryCategory({
|
||||
category: currentType as HealthHistoryCategory,
|
||||
data: {
|
||||
hasHistory: tempHasHistory ?? false,
|
||||
items: finalItems.map(item => ({
|
||||
name: item.name,
|
||||
date: item.date ? dayjs(item.date).format('YYYY-MM-DD') : undefined,
|
||||
isRecommendation: item.isRecommendation,
|
||||
})),
|
||||
},
|
||||
})).unwrap();
|
||||
|
||||
handleCloseModal();
|
||||
} catch (error: any) {
|
||||
// 如果保存失败,显示错误提示(本地数据已更新,下次打开会从服务端同步)
|
||||
Alert.alert(
|
||||
t('health.tabs.healthProfile.history.modal.saveError') || '保存失败',
|
||||
error?.message || '请稍后重试',
|
||||
[{ text: t('health.tabs.healthProfile.history.modal.ok') || '确定' }]
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add Item (Manual or Recommendation)
|
||||
const addItem = (name: string = '', isRecommendation: boolean = false) => {
|
||||
// Avoid duplicates for recommendations if already exists
|
||||
if (isRecommendation && tempItems.some(item => item.name === name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem: HistoryItemDetail = {
|
||||
id: Date.now().toString() + Math.random().toString(),
|
||||
name,
|
||||
isRecommendation
|
||||
};
|
||||
setTempItems([...tempItems, newItem]);
|
||||
};
|
||||
|
||||
// Remove Item
|
||||
const removeItem = (id: string) => {
|
||||
setTempItems(tempItems.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
// Update Item Name
|
||||
const updateItemName = (id: string, text: string) => {
|
||||
setTempItems(tempItems.map(item =>
|
||||
item.id === id ? { ...item, name: text } : item
|
||||
));
|
||||
};
|
||||
|
||||
// Date Picker Handlers
|
||||
const showDatePicker = (id: string) => {
|
||||
setCurrentEditingId(id);
|
||||
setDatePickerVisibility(true);
|
||||
};
|
||||
|
||||
const hideDatePicker = () => {
|
||||
setDatePickerVisibility(false);
|
||||
setCurrentEditingId(null);
|
||||
};
|
||||
|
||||
const handleConfirmDate = (date: Date) => {
|
||||
if (currentEditingId) {
|
||||
setTempItems(tempItems.map(item =>
|
||||
item.id === currentEditingId ? { ...item, date: date.toISOString() } : item
|
||||
));
|
||||
}
|
||||
hideDatePicker();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Glow effect background */}
|
||||
<View style={styles.glowContainer}>
|
||||
<View style={styles.glow} />
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>{t('health.tabs.healthProfile.healthHistory')}</Text>
|
||||
{isLoading && <ActivityIndicator size="small" color={palette.purple[500]} />}
|
||||
</View>
|
||||
|
||||
{/* List */}
|
||||
<View style={styles.list}>
|
||||
{historyItems.map((item) => (
|
||||
<HistoryItem
|
||||
key={item.key}
|
||||
title={item.title}
|
||||
categoryKey={item.key}
|
||||
data={historyData[item.key]}
|
||||
onPress={() => handleItemPress(item.key)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={modalVisible}
|
||||
onRequestClose={handleCloseModal}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
{/* Modal Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleCloseModal} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={24} color={palette.gray[400]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* Question: Do you have history? */}
|
||||
<Text style={styles.questionText}>
|
||||
{t('health.tabs.healthProfile.history.modal.question', {
|
||||
type: currentType ? t(`health.tabs.healthProfile.history.${currentType}`) : ''
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<View style={styles.radioGroup}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.radioButton,
|
||||
tempHasHistory === true && styles.radioButtonActive
|
||||
]}
|
||||
onPress={() => setTempHasHistory(true)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.radioText,
|
||||
tempHasHistory === true && styles.radioTextActive
|
||||
]}>{t('health.tabs.healthProfile.history.modal.yes')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.radioButton,
|
||||
tempHasHistory === false && styles.radioButtonActive
|
||||
]}
|
||||
onPress={() => setTempHasHistory(false)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.radioText,
|
||||
tempHasHistory === false && styles.radioTextActive
|
||||
]}>{t('health.tabs.healthProfile.history.modal.no')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Conditional Content */}
|
||||
{tempHasHistory === true && currentType && (
|
||||
<View style={styles.detailsContainer}>
|
||||
|
||||
{/* Recommendations */}
|
||||
{RECOMMENDATION_KEYS[currentType] && (
|
||||
<View style={styles.recommendationContainer}>
|
||||
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.recommendations')}</Text>
|
||||
<View style={styles.tagsContainer}>
|
||||
{RECOMMENDATION_KEYS[currentType].map((tagKey, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.tag}
|
||||
onPress={() => addItem(tagKey, true)}
|
||||
>
|
||||
<Text style={styles.tagText}>
|
||||
{t(`health.tabs.healthProfile.history.recommendationItems.${currentType}.${tagKey}`)}
|
||||
</Text>
|
||||
<Ionicons name="add" size={16} color={palette.gray[600]} style={{ marginLeft: 4 }} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* History List Items */}
|
||||
<View style={styles.listContainer}>
|
||||
<Text style={styles.sectionLabel}>{t('health.tabs.healthProfile.history.modal.addDetails')}</Text>
|
||||
|
||||
{tempItems.map((item) => (
|
||||
<View key={item.id} style={styles.listItemCard}>
|
||||
<View style={styles.listItemHeader}>
|
||||
<TextInput
|
||||
style={styles.listItemNameInput}
|
||||
placeholder={t('health.tabs.healthProfile.history.modal.namePlaceholder')}
|
||||
placeholderTextColor={palette.gray[300]}
|
||||
value={item.isRecommendation ? translateItem(currentType!, item.name) : item.name}
|
||||
onChangeText={(text) => updateItemName(item.id, text)}
|
||||
editable={!item.isRecommendation}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => removeItem(item.id)} style={styles.deleteButton}>
|
||||
<Ionicons name="trash-outline" size={20} color={palette.error[500]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.datePickerTrigger}
|
||||
onPress={() => showDatePicker(item.id)}
|
||||
>
|
||||
<Ionicons name="calendar-outline" size={18} color={palette.purple[500]} />
|
||||
<Text style={[
|
||||
styles.dateText,
|
||||
!item.date && styles.placeholderText
|
||||
]}>
|
||||
{item.date
|
||||
? dayjs(item.date).format('YYYY-MM-DD')
|
||||
: t('health.tabs.healthProfile.history.modal.selectDate')}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={palette.gray[400]} style={{ marginLeft: 'auto' }} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity style={styles.addItemButton} onPress={() => addItem()}>
|
||||
<Ionicons name="add-circle" size={20} color={palette.purple[500]} />
|
||||
<Text style={styles.addItemText}>{t('health.tabs.healthProfile.history.modal.addItem')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Save Button */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={isSaving ? [palette.gray[300], palette.gray[400]] : [palette.purple[500], palette.purple[700]]}
|
||||
style={styles.saveButtonGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
>
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>{t('health.tabs.healthProfile.history.modal.save')}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
<DateTimePickerModal
|
||||
isVisible={isDatePickerVisible}
|
||||
mode="date"
|
||||
onConfirm={handleConfirmDate}
|
||||
onCancel={hideDatePicker}
|
||||
maximumDate={new Date()} // Cannot select future date for history
|
||||
confirmTextIOS={t('health.tabs.healthProfile.history.modal.save')} // Reuse save
|
||||
cancelTextIOS={t('health.tabs.healthProfile.history.modal.none') === 'None' ? 'Cancel' : '取消'} // Fallback
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 16,
|
||||
position: 'relative',
|
||||
},
|
||||
glowContainer: {
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: -1,
|
||||
},
|
||||
glow: {
|
||||
width: '90%',
|
||||
height: '90%',
|
||||
backgroundColor: palette.purple[200],
|
||||
opacity: 0.3,
|
||||
borderRadius: 40,
|
||||
transform: [{ scale: 1.05 }],
|
||||
shadowColor: palette.purple[500],
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 20,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
shadowColor: palette.purple[100],
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 24,
|
||||
elevation: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#F5F3FF',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontFamily: 'AliBold',
|
||||
color: palette.gray[900],
|
||||
fontWeight: '600',
|
||||
},
|
||||
list: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
itemContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
itemContainerWithList: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
itemHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
},
|
||||
itemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
indicator: {
|
||||
width: 4,
|
||||
height: 14,
|
||||
borderRadius: 2,
|
||||
marginRight: 12,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
color: palette.gray[700],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
itemStatus: {
|
||||
fontSize: 14,
|
||||
color: palette.gray[300],
|
||||
fontFamily: 'AliRegular',
|
||||
textAlign: 'right',
|
||||
maxWidth: 150,
|
||||
},
|
||||
itemStatusActive: {
|
||||
color: palette.purple[600],
|
||||
fontWeight: '500',
|
||||
},
|
||||
subListContainer: {
|
||||
marginTop: 12,
|
||||
paddingLeft: 16, // Align with title (4px indicator + 12px margin)
|
||||
},
|
||||
subItemRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 6,
|
||||
},
|
||||
subItemDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: palette.purple[300],
|
||||
marginRight: 8,
|
||||
},
|
||||
subItemName: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
color: palette.gray[800],
|
||||
fontFamily: 'AliRegular',
|
||||
fontWeight: '500',
|
||||
},
|
||||
subItemDate: {
|
||||
fontSize: 13,
|
||||
color: palette.gray[400],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
// Modal Styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
padding: 24,
|
||||
maxHeight: '85%', // Increased height
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontFamily: 'AliBold',
|
||||
color: palette.gray[900],
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
questionText: {
|
||||
fontSize: 16,
|
||||
color: palette.gray[700],
|
||||
marginBottom: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
radioGroup: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 24,
|
||||
},
|
||||
radioButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.gray[200],
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
radioButtonActive: {
|
||||
backgroundColor: palette.purple[50],
|
||||
borderColor: palette.purple[500],
|
||||
},
|
||||
radioText: {
|
||||
fontSize: 16,
|
||||
color: palette.gray[600],
|
||||
fontWeight: '500',
|
||||
},
|
||||
radioTextActive: {
|
||||
color: palette.purple[600],
|
||||
fontWeight: '600',
|
||||
},
|
||||
detailsContainer: {
|
||||
marginTop: 4,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 14,
|
||||
color: palette.gray[500],
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
recommendationContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F5F7FA',
|
||||
},
|
||||
tagText: {
|
||||
fontSize: 14,
|
||||
color: palette.gray[600],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
listContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
listItemCard: {
|
||||
backgroundColor: '#F9FAFB',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.gray[100],
|
||||
},
|
||||
listItemHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
listItemNameInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: palette.gray[900],
|
||||
fontFamily: 'AliBold',
|
||||
padding: 0,
|
||||
},
|
||||
deleteButton: {
|
||||
padding: 4,
|
||||
},
|
||||
datePickerTrigger: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.gray[200],
|
||||
},
|
||||
dateText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: palette.gray[900],
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
placeholderText: {
|
||||
color: palette.gray[400],
|
||||
},
|
||||
addItemButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.purple[200],
|
||||
borderRadius: 12,
|
||||
borderStyle: 'dashed',
|
||||
backgroundColor: palette.purple[25],
|
||||
marginTop: 4,
|
||||
marginBottom: 20,
|
||||
},
|
||||
addItemText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: palette.purple[600],
|
||||
fontWeight: '500',
|
||||
},
|
||||
modalFooter: {
|
||||
marginTop: 8,
|
||||
},
|
||||
saveButton: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: palette.purple[500],
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
shadowOpacity: 0,
|
||||
},
|
||||
saveButtonGradient: {
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
666
components/health/tabs/MedicalRecordsTab.tsx
Normal file
666
components/health/tabs/MedicalRecordsTab.tsx
Normal file
@@ -0,0 +1,666 @@
|
||||
import { MedicalRecordCard } from '@/components/health/MedicalRecordCard';
|
||||
import { palette } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { MedicalRecordItem, MedicalRecordType } from '@/services/healthProfile';
|
||||
import {
|
||||
addNewMedicalRecord,
|
||||
deleteMedicalRecordItem,
|
||||
fetchMedicalRecords,
|
||||
selectHealthLoading,
|
||||
selectMedicalRecords,
|
||||
} from '@/store/healthSlice';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
|
||||
export function MedicalRecordsTab() {
|
||||
const dispatch = useAppDispatch();
|
||||
const medicalRecords = useAppSelector(selectMedicalRecords);
|
||||
const records = medicalRecords?.records || [];
|
||||
const prescriptions = medicalRecords?.prescriptions || [];
|
||||
const isLoading = useAppSelector(selectHealthLoading);
|
||||
|
||||
// COS 上传
|
||||
const { upload: uploadToCos, uploading: isUploading } = useCosUpload({
|
||||
prefix: 'images/health/medical-records'
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<MedicalRecordType>('medical_record');
|
||||
const [isModalVisible, setModalVisible] = useState(false);
|
||||
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [title, setTitle] = useState('');
|
||||
const [date, setDate] = useState(new Date());
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [note, setNote] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Image Viewer State
|
||||
const [viewerVisible, setViewerVisible] = useState(false);
|
||||
const [currentViewerImages, setCurrentViewerImages] = useState<{ uri: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMedicalRecords());
|
||||
}, [dispatch]);
|
||||
|
||||
const currentList = activeTab === 'medical_record' ? records : prescriptions;
|
||||
|
||||
const handleTabPress = (tab: MedicalRecordType) => {
|
||||
setActiveTab(tab);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setDate(new Date());
|
||||
setImages([]);
|
||||
setNote('');
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
resetForm();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handlePickImage = async () => {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('需要权限', '请允许访问相册以上传图片');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setImages([...images, result.assets[0].uri]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTakePhoto = async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('需要权限', '请允许访问相机以拍摄照片');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setImages([...images, result.assets[0].uri]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickDocument = async () => {
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: ['application/pdf', 'image/*'],
|
||||
copyToCacheDirectory: true,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
setImages([...images, result.assets[0].uri]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error picking document:', error);
|
||||
Alert.alert('错误', '选择文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
Alert.alert('提示', '请输入标题');
|
||||
return;
|
||||
}
|
||||
if (images.length === 0) {
|
||||
Alert.alert('提示', '请至少上传一张图片');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 1. 上传所有图片到 COS
|
||||
const uploadPromises = images.map(async (uri) => {
|
||||
const result = await uploadToCos({ uri });
|
||||
return result.url;
|
||||
});
|
||||
|
||||
const uploadedUrls = await Promise.all(uploadPromises);
|
||||
|
||||
// 2. 创建就医资料记录
|
||||
await dispatch(addNewMedicalRecord({
|
||||
type: activeTab,
|
||||
title: title.trim(),
|
||||
date: dayjs(date).format('YYYY-MM-DD'),
|
||||
images: uploadedUrls,
|
||||
note: note.trim() || undefined,
|
||||
})).unwrap();
|
||||
|
||||
setModalVisible(false);
|
||||
resetForm();
|
||||
} catch (error: any) {
|
||||
console.error('保存失败:', error);
|
||||
const errorMessage = error?.message || '保存失败,请重试';
|
||||
Alert.alert('错误', errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (item: MedicalRecordItem) => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'确定要删除这条记录吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: () => dispatch(deleteMedicalRecordItem({ id: item.id, type: item.type })),
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleViewImages = (item: MedicalRecordItem) => {
|
||||
if (item.images && item.images.length > 0) {
|
||||
setCurrentViewerImages(item.images.map(uri => ({ uri })));
|
||||
setViewerVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: MedicalRecordItem }) => (
|
||||
<MedicalRecordCard
|
||||
item={item}
|
||||
onPress={handleViewImages}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Segmented Control */}
|
||||
<View style={styles.segmentContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.segmentButton, activeTab === 'medical_record' && styles.segmentButtonActive]}
|
||||
onPress={() => handleTabPress('medical_record')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.segmentText, activeTab === 'medical_record' && styles.segmentTextActive]}>
|
||||
病历资料
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.segmentButton, activeTab === 'prescription' && styles.segmentButtonActive]}
|
||||
onPress={() => handleTabPress('prescription')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[styles.segmentText, activeTab === 'prescription' && styles.segmentTextActive]}>
|
||||
处方单据
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content List */}
|
||||
<View style={styles.contentContainer}>
|
||||
{isLoading && records.length === 0 && prescriptions.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={palette.purple[500]} />
|
||||
</View>
|
||||
) : currentList.length > 0 ? (
|
||||
<FlatList
|
||||
data={currentList}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
scrollEnabled={false} // Since it's inside a parent ScrollView
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.emptyIconContainer}>
|
||||
<Ionicons
|
||||
name={activeTab === 'medical_record' ? "folder-open-outline" : "receipt-outline"}
|
||||
size={48}
|
||||
color={palette.gray[300]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.emptyText}>
|
||||
{activeTab === 'medical_record' ? '暂无病历资料' : '暂无处方单据'}
|
||||
</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
{activeTab === 'medical_record' ? '上传您的检查报告、诊断证明等' : '上传您的处方单、用药清单等'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Add Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.fab}
|
||||
onPress={openAddModal}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[palette.purple[500], palette.purple[700]]}
|
||||
style={styles.fabGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="add" size={28} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
visible={isModalVisible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)} style={styles.modalCloseButton}>
|
||||
<Text style={styles.modalCloseText}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.modalTitle}>
|
||||
{activeTab === 'medical_record' ? '添加病历' : '添加处方'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit}
|
||||
style={[styles.modalSaveButton, (isSubmitting || isUploading) && styles.modalSaveButtonDisabled]}
|
||||
disabled={isSubmitting || isUploading}
|
||||
>
|
||||
{(isSubmitting || isUploading) ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.modalSaveText}>保存</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.formContainer}>
|
||||
{/* Title Input */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>标题 <Text style={styles.required}>*</Text></Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={activeTab === 'medical_record' ? "例如:血常规检查" : "例如:感冒药处方"}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholderTextColor={palette.gray[400]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Date Picker */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>日期</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.dateInput}
|
||||
onPress={() => setDatePickerVisibility(true)}
|
||||
>
|
||||
<Text style={styles.dateText}>{dayjs(date).format('YYYY年MM月DD日')}</Text>
|
||||
<Ionicons name="calendar-outline" size={20} color={palette.gray[500]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Images */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>图片资料 <Text style={styles.required}>*</Text></Text>
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((uri, index) => {
|
||||
const isPdf = uri.toLowerCase().endsWith('.pdf');
|
||||
return (
|
||||
<View key={index} style={styles.imagePreviewContainer}>
|
||||
{isPdf ? (
|
||||
<View style={[styles.imagePreview, styles.pdfPreview]}>
|
||||
<Ionicons name="document-text" size={32} color="#EF4444" />
|
||||
<Text style={styles.pdfText} numberOfLines={1}>PDF</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={styles.imagePreview}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => setImages(images.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color={palette.error[500]} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{images.length < 9 && (
|
||||
<TouchableOpacity style={styles.addImageButton} onPress={() => {
|
||||
Alert.alert(
|
||||
'上传文件',
|
||||
'请选择上传方式',
|
||||
[
|
||||
{ text: '拍照', onPress: handleTakePhoto },
|
||||
{ text: '从相册选择', onPress: handlePickImage },
|
||||
{ text: '选择文档 (PDF)', onPress: handlePickDocument },
|
||||
{ text: '取消', style: 'cancel' },
|
||||
]
|
||||
);
|
||||
}}>
|
||||
<Ionicons name="add" size={32} color={palette.purple[500]} />
|
||||
<Text style={styles.addImageText}>上传</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Note */}
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>备注</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
placeholder="添加备注信息..."
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
placeholderTextColor={palette.gray[400]}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<DateTimePickerModal
|
||||
isVisible={isDatePickerVisible}
|
||||
mode="date"
|
||||
onConfirm={(d) => {
|
||||
setDate(d);
|
||||
setDatePickerVisibility(false);
|
||||
}}
|
||||
onCancel={() => setDatePickerVisibility(false)}
|
||||
maximumDate={new Date()}
|
||||
locale="zh_CN"
|
||||
confirmTextIOS="确定"
|
||||
cancelTextIOS="取消"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ImageViewing
|
||||
images={currentViewerImages}
|
||||
imageIndex={0}
|
||||
visible={viewerVisible}
|
||||
onRequestClose={() => setViewerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
segmentContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F3F4F6',
|
||||
borderRadius: 12,
|
||||
padding: 4,
|
||||
marginBottom: 16,
|
||||
},
|
||||
segmentButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
borderRadius: 10,
|
||||
},
|
||||
segmentButtonActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
segmentText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: palette.purple[600],
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
contentContainer: {
|
||||
minHeight: 300,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 80,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 40,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#F9FAFB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#374151',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 13,
|
||||
color: '#9CA3AF',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
fab: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
shadowColor: palette.purple[500],
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 6,
|
||||
},
|
||||
fabGradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// Modal Styles
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: '#E5E7EB',
|
||||
paddingTop: Platform.OS === 'ios' ? 12 : 12,
|
||||
},
|
||||
modalCloseButton: {
|
||||
padding: 8,
|
||||
},
|
||||
modalCloseText: {
|
||||
fontSize: 16,
|
||||
color: '#6B7280',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
modalSaveButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: palette.purple[600],
|
||||
borderRadius: 6,
|
||||
},
|
||||
modalSaveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
modalSaveText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
formContainer: {
|
||||
padding: 16,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
marginBottom: 8,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
required: {
|
||||
color: palette.error[500],
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#111827',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
textArea: {
|
||||
height: 100,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
dateInput: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
},
|
||||
dateText: {
|
||||
fontSize: 16,
|
||||
color: '#111827',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
imageGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
imagePreview: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
pdfPreview: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F3F4F6',
|
||||
},
|
||||
pdfText: {
|
||||
fontSize: 10,
|
||||
marginTop: 4,
|
||||
color: '#EF4444',
|
||||
fontWeight: '600',
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: 10,
|
||||
},
|
||||
addImageButton: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.purple[200],
|
||||
borderStyle: 'dashed',
|
||||
backgroundColor: palette.purple[50],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
addImageText: {
|
||||
fontSize: 12,
|
||||
color: palette.purple[600],
|
||||
marginTop: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
409
components/medication/MedicationAddOptionsSheet.tsx
Normal file
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
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
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',
|
||||
},
|
||||
});
|
||||
276
components/medications/MedicationPhotoGuideModal.tsx
Normal file
276
components/medications/MedicationPhotoGuideModal.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
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) {
|
||||
const { t } = useI18n();
|
||||
|
||||
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}>
|
||||
{t('medications.aiCamera.guideModal.badge')}
|
||||
</Text>
|
||||
<Text style={styles.guideTitle}>
|
||||
{t('medications.aiCamera.guideModal.title')}
|
||||
</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}>
|
||||
{t('medications.aiCamera.guideModal.description1')}
|
||||
</Text>
|
||||
<Text style={styles.guideDescriptionText}>
|
||||
{t('medications.aiCamera.guideModal.description2')}
|
||||
</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}>
|
||||
{t('medications.aiCamera.guideModal.button')}
|
||||
</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}>
|
||||
{t('medications.aiCamera.guideModal.button')}
|
||||
</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',
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import CustomCheckBox from '@/components/ui/CheckBox';
|
||||
import { USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { useAppDispatch } from '@/hooks/redux';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import {
|
||||
MEMBERSHIP_PLAN_META,
|
||||
extractMembershipProductsFromOfferings,
|
||||
@@ -65,51 +66,6 @@ interface BenefitItem {
|
||||
regular: PermissionConfig;
|
||||
}
|
||||
|
||||
// 权益对比配置
|
||||
const BENEFIT_COMPARISON: BenefitItem[] = [
|
||||
{
|
||||
title: 'AI拍照记录热量',
|
||||
description: '通过拍照识别食物并自动记录热量',
|
||||
vip: {
|
||||
type: 'unlimited',
|
||||
text: '无限次使用',
|
||||
vipText: '无限次使用'
|
||||
},
|
||||
regular: {
|
||||
type: 'limited',
|
||||
text: '有限次使用',
|
||||
vipText: '每日3次'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'AI拍照识别包装',
|
||||
description: '识别食品包装上的营养成分信息',
|
||||
vip: {
|
||||
type: 'unlimited',
|
||||
text: '无限次使用',
|
||||
vipText: '无限次使用'
|
||||
},
|
||||
regular: {
|
||||
type: 'limited',
|
||||
text: '有限次使用',
|
||||
vipText: '每日5次'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '每日健康提醒',
|
||||
description: '根据个人目标提供个性化健康提醒',
|
||||
vip: {
|
||||
type: 'unlimited',
|
||||
text: '完全支持',
|
||||
vipText: '智能提醒'
|
||||
},
|
||||
regular: {
|
||||
type: 'unlimited',
|
||||
text: '基础提醒',
|
||||
vipText: '基础提醒'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const PLAN_STYLE_CONFIG: Record<MembershipPlanType, { gradient: readonly [string, string]; accent: string }> = {
|
||||
lifetime: {
|
||||
@@ -151,6 +107,7 @@ const getPermissionIcon = (type: PermissionType, isVip: boolean) => {
|
||||
};
|
||||
|
||||
export function MembershipModal({ visible, onClose, onPurchaseSuccess }: MembershipModalProps) {
|
||||
const { t } = useI18n();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedProduct, setSelectedProduct] = useState<PurchasesStoreProduct | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -165,6 +122,94 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
// 保存监听器引用,用于移除监听器
|
||||
const purchaseListenerRef = useRef<((customerInfo: CustomerInfo) => void) | null>(null);
|
||||
|
||||
// 权益对比配置 - Move inside component to use t function
|
||||
const benefitComparison: BenefitItem[] = [
|
||||
{
|
||||
title: t('membershipModal.benefits.items.aiCalories.title'),
|
||||
description: t('membershipModal.benefits.items.aiCalories.description'),
|
||||
vip: {
|
||||
type: 'unlimited',
|
||||
text: t('membershipModal.benefits.permissions.unlimited'),
|
||||
vipText: t('membershipModal.benefits.permissions.unlimited')
|
||||
},
|
||||
regular: {
|
||||
type: 'limited',
|
||||
text: t('membershipModal.benefits.permissions.limited'),
|
||||
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 3 })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('membershipModal.benefits.items.aiNutrition.title'),
|
||||
description: t('membershipModal.benefits.items.aiNutrition.description'),
|
||||
vip: {
|
||||
type: 'unlimited',
|
||||
text: t('membershipModal.benefits.permissions.unlimited'),
|
||||
vipText: t('membershipModal.benefits.permissions.unlimited')
|
||||
},
|
||||
regular: {
|
||||
type: 'limited',
|
||||
text: t('membershipModal.benefits.permissions.limited'),
|
||||
vipText: t('membershipModal.benefits.permissions.dailyLimit', { count: 5 })
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('membershipModal.benefits.items.healthReminder.title'),
|
||||
description: t('membershipModal.benefits.items.healthReminder.description'),
|
||||
vip: {
|
||||
type: 'unlimited',
|
||||
text: t('membershipModal.benefits.permissions.fullSupport'),
|
||||
vipText: t('membershipModal.benefits.permissions.smartReminder')
|
||||
},
|
||||
regular: {
|
||||
type: 'unlimited',
|
||||
text: t('membershipModal.benefits.permissions.basicSupport'),
|
||||
vipText: t('membershipModal.benefits.permissions.basicSupport')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('membershipModal.benefits.items.aiMedication.title'),
|
||||
description: t('membershipModal.benefits.items.aiMedication.description'),
|
||||
vip: {
|
||||
type: 'exclusive',
|
||||
text: t('membershipModal.benefits.permissions.fullAnalysis'),
|
||||
vipText: t('membershipModal.benefits.permissions.fullAnalysis')
|
||||
},
|
||||
regular: {
|
||||
type: 'exclusive',
|
||||
text: t('membershipModal.benefits.permissions.notSupported'),
|
||||
vipText: t('membershipModal.benefits.permissions.notSupported')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('membershipModal.benefits.items.customChallenge.title'),
|
||||
description: t('membershipModal.benefits.items.customChallenge.description'),
|
||||
vip: {
|
||||
type: 'exclusive',
|
||||
text: t('membershipModal.benefits.permissions.createUnlimited'),
|
||||
vipText: t('membershipModal.benefits.permissions.createUnlimited')
|
||||
},
|
||||
regular: {
|
||||
type: 'exclusive',
|
||||
text: t('membershipModal.benefits.permissions.notSupported'),
|
||||
vipText: t('membershipModal.benefits.permissions.notSupported')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('membershipModal.benefits.items.tabBarCustomization.title'),
|
||||
description: t('membershipModal.benefits.items.tabBarCustomization.description'),
|
||||
vip: {
|
||||
type: 'exclusive',
|
||||
text: t('membershipModal.benefits.permissions.fullSupport'),
|
||||
vipText: t('membershipModal.benefits.permissions.unlimited')
|
||||
},
|
||||
regular: {
|
||||
type: 'exclusive',
|
||||
text: t('membershipModal.benefits.permissions.notSupported'),
|
||||
vipText: t('membershipModal.benefits.permissions.notSupported')
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// 根据选中的产品生成tips内容
|
||||
const getTipsContent = (product: PurchasesStoreProduct | null): string => {
|
||||
if (!product) return '';
|
||||
@@ -176,11 +221,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
|
||||
switch (plan.type) {
|
||||
case 'lifetime':
|
||||
return '终身陪伴,见证您的每一次健康蜕变';
|
||||
return t('membershipModal.plans.lifetime.subtitle');
|
||||
case 'quarterly':
|
||||
return '3个月科学计划,让健康成为生活习惯';
|
||||
return t('membershipModal.plans.quarterly.subtitle');
|
||||
case 'weekly':
|
||||
return '7天体验期,感受专业健康指导的力量';
|
||||
return t('membershipModal.plans.weekly.subtitle');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -326,7 +371,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
|
||||
// 显示成功提示
|
||||
GlobalToast.show({
|
||||
message: '会员开通成功',
|
||||
message: t('membershipModal.success.purchase'),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
@@ -492,11 +537,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
// 验证是否已同意协议
|
||||
if (!agreementAccepted) {
|
||||
Alert.alert(
|
||||
'请阅读并同意相关协议',
|
||||
'购买前需要同意用户协议、会员协议和自动续费协议',
|
||||
t('membershipModal.agreements.alert.title'),
|
||||
t('membershipModal.agreements.alert.message'),
|
||||
[
|
||||
{
|
||||
text: '确定',
|
||||
text: t('membershipModal.agreements.alert.confirm'),
|
||||
style: 'default',
|
||||
}
|
||||
]
|
||||
@@ -517,11 +562,11 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
// 验证是否选择了产品
|
||||
if (!selectedProduct) {
|
||||
Alert.alert(
|
||||
'请选择会员套餐',
|
||||
t('membershipModal.errors.selectPlan'),
|
||||
'',
|
||||
[
|
||||
{
|
||||
text: '确定',
|
||||
text: t('membershipModal.agreements.alert.confirm'),
|
||||
style: 'default',
|
||||
}
|
||||
]
|
||||
@@ -579,32 +624,32 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
|
||||
// 用户取消购买
|
||||
GlobalToast.show({
|
||||
message: '购买已取消',
|
||||
message: t('membershipModal.errors.purchaseCancelled'),
|
||||
});
|
||||
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PRODUCT_ALREADY_PURCHASED_ERROR) {
|
||||
// 商品已拥有
|
||||
GlobalToast.show({
|
||||
message: '您已拥有此商品',
|
||||
message: t('membershipModal.errors.alreadyPurchased'),
|
||||
});
|
||||
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
|
||||
// 网络错误
|
||||
GlobalToast.show({
|
||||
message: '网络连接失败',
|
||||
message: t('membershipModal.errors.networkError'),
|
||||
});
|
||||
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.PAYMENT_PENDING_ERROR) {
|
||||
// 支付待处理
|
||||
GlobalToast.show({
|
||||
message: '支付正在处理中',
|
||||
message: t('membershipModal.errors.paymentPending'),
|
||||
});
|
||||
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
|
||||
// 凭据无效
|
||||
GlobalToast.show({
|
||||
message: '账户验证失败',
|
||||
message: t('membershipModal.errors.invalidCredentials'),
|
||||
});
|
||||
} else {
|
||||
// 其他错误
|
||||
GlobalToast.show({
|
||||
message: '购买失败',
|
||||
message: t('membershipModal.errors.purchaseFailed'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -701,7 +746,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
onClose?.();
|
||||
|
||||
GlobalToast.show({
|
||||
message: '恢复购买成功',
|
||||
message: t('membershipModal.errors.restoreSuccess'),
|
||||
});
|
||||
|
||||
} catch (apiError: any) {
|
||||
@@ -720,7 +765,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
// 即使后台接口失败,也显示恢复成功(因为 RevenueCat 已经确认有购买记录)
|
||||
// 但不关闭弹窗,让用户知道可能需要重试
|
||||
GlobalToast.show({
|
||||
message: '恢复购买部分失败',
|
||||
message: t('membershipModal.errors.restorePartialFailed'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -734,7 +779,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
activeSubscriptionsCount: activeSubscriptionIds.length
|
||||
});
|
||||
GlobalToast.show({
|
||||
message: '没有找到购买记录',
|
||||
message: t('membershipModal.errors.noPurchasesFound'),
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -754,19 +799,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
// 处理特定的恢复购买错误
|
||||
if (error.userCancelled || error.code === Purchases.PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR) {
|
||||
GlobalToast.show({
|
||||
message: '恢复购买已取消',
|
||||
message: t('membershipModal.errors.restoreCancelled'),
|
||||
});
|
||||
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.NETWORK_ERROR) {
|
||||
GlobalToast.show({
|
||||
message: '网络错误',
|
||||
message: t('membershipModal.errors.networkError'),
|
||||
});
|
||||
} else if (error.code === Purchases.PURCHASES_ERROR_CODE.INVALID_CREDENTIALS_ERROR) {
|
||||
GlobalToast.show({
|
||||
message: '账户验证失败',
|
||||
message: t('membershipModal.errors.invalidCredentials'),
|
||||
});
|
||||
} else {
|
||||
GlobalToast.show({
|
||||
message: '恢复购买失败',
|
||||
message: t('membershipModal.errors.restoreFailed'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -780,7 +825,19 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
const renderPlanCard = (product: PurchasesStoreProduct) => {
|
||||
const planMeta = getPlanMetaById(product.identifier);
|
||||
const isSelected = selectedProduct === product;
|
||||
const displayTitle = resolvePlanDisplayName(product, planMeta);
|
||||
|
||||
// 优先使用翻译的标题,如果找不到 meta 则回退到产品标题
|
||||
let displayTitle = product.title;
|
||||
let displaySubtitle = planMeta?.subtitle ?? '';
|
||||
|
||||
if (planMeta) {
|
||||
displayTitle = t(`membershipModal.plans.${planMeta.type}.title`);
|
||||
displaySubtitle = t(`membershipModal.plans.${planMeta.type}.subtitle`);
|
||||
} else {
|
||||
// 如果没有 meta,尝试使用 resolvePlanDisplayName (虽然这里主要依赖 meta)
|
||||
displayTitle = resolvePlanDisplayName(product, planMeta);
|
||||
}
|
||||
|
||||
const priceLabel = product.priceString || '';
|
||||
const styleConfig = planMeta ? PLAN_STYLE_CONFIG[planMeta.type] : undefined;
|
||||
|
||||
@@ -797,7 +854,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
activeOpacity={loading ? 1 : 0.8}
|
||||
accessible={true}
|
||||
accessibilityLabel={`${displayTitle} ${priceLabel}`}
|
||||
accessibilityHint={loading ? '购买进行中,无法切换套餐' : `选择${displayTitle}套餐`}
|
||||
accessibilityHint={loading ? t('membershipModal.loading.purchase') : t('membershipModal.actions.selectPlan', { plan: displayTitle })}
|
||||
accessibilityState={{ disabled: loading, selected: isSelected }}
|
||||
>
|
||||
<LinearGradient
|
||||
@@ -809,7 +866,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
<View style={styles.planCardTopSection}>
|
||||
{planMeta?.tag && (
|
||||
<View style={styles.planTag}>
|
||||
<Text style={styles.planTagText}>{planMeta.tag}</Text>
|
||||
<Text style={styles.planTagText}>{t('membershipModal.plans.tag')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.planCardTitle}>{displayTitle}</Text>
|
||||
@@ -825,7 +882,7 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
</View>
|
||||
|
||||
<View style={styles.planCardBottomSection}>
|
||||
<Text style={styles.planCardDescription}>{planMeta?.subtitle ?? ''}</Text>
|
||||
<Text style={styles.planCardDescription}>{displaySubtitle}</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -854,8 +911,8 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
onPress={onClose}
|
||||
activeOpacity={0.7}
|
||||
accessible={true}
|
||||
accessibilityLabel="返回"
|
||||
accessibilityHint="关闭会员购买弹窗"
|
||||
accessibilityLabel={t('membershipModal.actions.back')}
|
||||
accessibilityHint={t('membershipModal.actions.close')}
|
||||
style={styles.floatingBackButtonContainer}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
@@ -887,14 +944,14 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
<View style={styles.sectionTitleBadge}>
|
||||
<Ionicons name="star" size={16} color="#7B2CBF" />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>会员套餐</Text>
|
||||
<Text style={styles.sectionTitle}>{t('membershipModal.sectionTitle.plans')}</Text>
|
||||
</View>
|
||||
<Text style={styles.sectionSubtitle}>灵活选择,跟随节奏稳步提升</Text>
|
||||
<Text style={styles.sectionSubtitle}>{t('membershipModal.sectionTitle.plansSubtitle')}</Text>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<View style={styles.configurationNotice}>
|
||||
<Text style={styles.configurationText}>
|
||||
暂未获取到会员商品,请在 RevenueCat 中配置 iOS 产品并同步到当前 Offering。
|
||||
{t('membershipModal.errors.noProducts')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@@ -917,17 +974,17 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
<View style={styles.sectionTitleBadge}>
|
||||
<Ionicons name="checkbox" size={16} color="#FF9F0A" />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>权益对比</Text>
|
||||
<Text style={styles.sectionTitle}>{t('membershipModal.benefits.title')}</Text>
|
||||
</View>
|
||||
<Text style={styles.sectionSubtitle}>核心权益一目了然,选择更安心</Text>
|
||||
<Text style={styles.sectionSubtitle}>{t('membershipModal.benefits.subtitle')}</Text>
|
||||
|
||||
<View style={styles.comparisonTable}>
|
||||
<View style={[styles.tableRow, styles.tableHeader]}>
|
||||
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}>权益</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>VIP</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}>普通用户</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.tableTitleCell]}>{t('membershipModal.benefits.table.benefit')}</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.tableVipCell]}>{t('membershipModal.benefits.table.vip')}</Text>
|
||||
<Text style={[styles.tableHeaderText, styles.tableNormalCell]}>{t('membershipModal.benefits.table.regular')}</Text>
|
||||
</View>
|
||||
{BENEFIT_COMPARISON.map((row, index) => (
|
||||
{benefitComparison.map((row, index) => (
|
||||
<View
|
||||
key={row.title}
|
||||
style={[
|
||||
@@ -963,39 +1020,46 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomSection}>
|
||||
<View style={styles.agreementRow}>
|
||||
<CustomCheckBox
|
||||
checked={agreementAccepted}
|
||||
onCheckedChange={setAgreementAccepted}
|
||||
size={16}
|
||||
checkedColor="#E91E63"
|
||||
uncheckedColor="#999"
|
||||
/>
|
||||
<Text style={styles.agreementPrefix}>开通即视为同意</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(USER_AGREEMENT_URL);
|
||||
captureMessage('click user agreement');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.agreementLink}>《用户协议》</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.agreementSeparator}>|</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
captureMessage('click membership agreement');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.agreementLink}>《会员协议》</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.agreementSeparator}>|</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
captureMessage('click auto renewal agreement');
|
||||
}}
|
||||
>
|
||||
<Text style={styles.agreementLink}>《自动续费协议》</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.agreementContainer}>
|
||||
<View style={styles.checkboxWrapper}>
|
||||
<CustomCheckBox
|
||||
checked={agreementAccepted}
|
||||
onCheckedChange={setAgreementAccepted}
|
||||
size={16}
|
||||
checkedColor="#E91E63"
|
||||
uncheckedColor="#999"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.agreementText}>
|
||||
{t('membershipModal.agreements.prefix')}
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={() => {
|
||||
Linking.openURL(USER_AGREEMENT_URL);
|
||||
captureMessage('click user agreement');
|
||||
}}
|
||||
>
|
||||
{t('membershipModal.agreements.userAgreement')}
|
||||
</Text>
|
||||
<Text style={styles.agreementSeparator}> | </Text>
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={() => {
|
||||
captureMessage('click membership agreement');
|
||||
}}
|
||||
>
|
||||
{t('membershipModal.agreements.membershipAgreement')}
|
||||
</Text>
|
||||
<Text style={styles.agreementSeparator}> | </Text>
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={() => {
|
||||
captureMessage('click auto renewal agreement');
|
||||
}}
|
||||
>
|
||||
{t('membershipModal.agreements.autoRenewalAgreement')}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -1006,10 +1070,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
{restoring ? (
|
||||
<View style={styles.restoreButtonContent}>
|
||||
<ActivityIndicator size="small" color="#666" style={styles.restoreButtonLoader} />
|
||||
<Text style={styles.restoreButtonText}>恢复中...</Text>
|
||||
<Text style={styles.restoreButtonText}>{t('membershipModal.actions.restoring')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.restoreButtonText}>恢复购买</Text>
|
||||
<Text style={styles.restoreButtonText}>{t('membershipModal.actions.restore')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -1031,15 +1095,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
onPress={handlePurchase}
|
||||
disabled={loading || products.length === 0}
|
||||
accessible={true}
|
||||
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
|
||||
accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
|
||||
accessibilityHint={
|
||||
loading
|
||||
? '购买正在进行中,请稍候'
|
||||
? t('membershipModal.loading.purchase')
|
||||
: products.length === 0
|
||||
? '正在加载会员套餐,请稍候'
|
||||
? t('membershipModal.loading.products')
|
||||
: !selectedProduct
|
||||
? '请选择会员套餐后再进行购买'
|
||||
: `点击购买${selectedProduct.title || '已选'}会员套餐`
|
||||
? t('membershipModal.errors.selectPlan')
|
||||
: t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
|
||||
}
|
||||
accessibilityState={{ disabled: loading || products.length === 0 }}
|
||||
style={styles.purchaseButtonContent}
|
||||
@@ -1047,10 +1111,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
|
||||
<Text style={styles.purchaseButtonText}>正在处理购买...</Text>
|
||||
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.purchaseButtonText}>立即订阅</Text>
|
||||
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</GlassView>
|
||||
@@ -1066,15 +1130,15 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
onPress={handlePurchase}
|
||||
disabled={loading || products.length === 0}
|
||||
accessible={true}
|
||||
accessibilityLabel={loading ? '正在处理购买' : '购买会员'}
|
||||
accessibilityLabel={loading ? t('membershipModal.actions.processing') : t('membershipModal.actions.subscribe')}
|
||||
accessibilityHint={
|
||||
loading
|
||||
? '购买正在进行中,请稍候'
|
||||
? t('membershipModal.loading.purchase')
|
||||
: products.length === 0
|
||||
? '正在加载会员套餐,请稍候'
|
||||
? t('membershipModal.loading.products')
|
||||
: !selectedProduct
|
||||
? '请选择会员套餐后再进行购买'
|
||||
: `点击购买${selectedProduct.title || '已选'}会员套餐`
|
||||
? t('membershipModal.errors.selectPlan')
|
||||
: t('membershipModal.actions.purchaseHint', { plan: selectedProduct.title || '已选' })
|
||||
}
|
||||
accessibilityState={{ disabled: loading || products.length === 0 }}
|
||||
style={styles.purchaseButtonContent}
|
||||
@@ -1082,10 +1146,10 @@ export function MembershipModal({ visible, onClose, onPurchaseSuccess }: Members
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color="white" style={styles.loadingSpinner} />
|
||||
<Text style={styles.purchaseButtonText}>正在处理购买...</Text>
|
||||
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.processing')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.purchaseButtonText}>立即订阅</Text>
|
||||
<Text style={styles.purchaseButtonText}>{t('membershipModal.actions.subscribe')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -1168,12 +1232,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#2B2B2E',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sectionSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#6B6B73',
|
||||
marginTop: 6,
|
||||
marginBottom: 16,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
configurationNotice: {
|
||||
borderRadius: 16,
|
||||
@@ -1185,6 +1251,7 @@ const styles = StyleSheet.create({
|
||||
color: '#B86A04',
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
plansContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -1217,35 +1284,40 @@ const styles = StyleSheet.create({
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#2F2F36',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 6,
|
||||
marginBottom: 12,
|
||||
},
|
||||
planTagText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
planCardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#241F1F',
|
||||
},
|
||||
planCardPrice: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#241F1F',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
planCardPrice: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginTop: 12,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
planCardOriginalPrice: {
|
||||
fontSize: 13,
|
||||
color: '#8E8EA1',
|
||||
textDecorationLine: 'line-through',
|
||||
marginTop: 2,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
planCardDescription: {
|
||||
fontSize: 12,
|
||||
color: '#6C6C77',
|
||||
lineHeight: 17,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
planCardTopSection: {
|
||||
flex: 1,
|
||||
@@ -1275,6 +1347,7 @@ const styles = StyleSheet.create({
|
||||
color: '#9B6200',
|
||||
marginLeft: 6,
|
||||
lineHeight: 16,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
comparisonTable: {
|
||||
borderRadius: 16,
|
||||
@@ -1298,10 +1371,12 @@ const styles = StyleSheet.create({
|
||||
color: '#575764',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.4,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
tableCellText: {
|
||||
fontSize: 13,
|
||||
color: '#3E3E44',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
tableTitleCell: {
|
||||
flex: 1.5,
|
||||
@@ -1361,6 +1436,7 @@ const styles = StyleSheet.create({
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
loadingContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -1369,29 +1445,34 @@ const styles = StyleSheet.create({
|
||||
loadingSpinner: {
|
||||
marginRight: 8,
|
||||
},
|
||||
agreementRow: {
|
||||
agreementContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
agreementPrefix: {
|
||||
fontSize: 10,
|
||||
checkboxWrapper: {
|
||||
marginTop: 2, // Align with text line-height
|
||||
marginRight: 8,
|
||||
},
|
||||
agreementText: {
|
||||
flex: 1,
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
color: '#666672',
|
||||
marginRight: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
agreementLink: {
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
color: '#E91E63',
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '500',
|
||||
marginHorizontal: 2,
|
||||
textDecorationLine: 'underline',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
agreementSeparator: {
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
color: '#A0A0B0',
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
restoreButton: {
|
||||
alignSelf: 'center',
|
||||
@@ -1401,6 +1482,7 @@ const styles = StyleSheet.create({
|
||||
color: '#6F6F7A',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
disabledRestoreButton: {
|
||||
opacity: 0.5,
|
||||
@@ -1422,6 +1504,7 @@ const styles = StyleSheet.create({
|
||||
color: '#8E8E93',
|
||||
marginTop: 2,
|
||||
lineHeight: 14,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
permissionContainer: {
|
||||
alignItems: 'center',
|
||||
@@ -1435,5 +1518,6 @@ const styles = StyleSheet.create({
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
lineHeight: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Image } from 'expo-image';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -15,11 +20,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useAppSelector } from '@/hooks/redux';
|
||||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
|
||||
const CTA_GRADIENT: [string, string] = ['#5E8BFF', '#6B6CFF'];
|
||||
const CTA_DISABLED_GRADIENT: [string, string] = ['#d3d7e8', '#c1c6da'];
|
||||
|
||||
export interface CreateCustomFoodModalProps {
|
||||
visible: boolean;
|
||||
@@ -43,9 +48,10 @@ export function CreateCustomFoodModal({
|
||||
onClose,
|
||||
onSave
|
||||
}: CreateCustomFoodModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [foodName, setFoodName] = useState('');
|
||||
const [defaultAmount, setDefaultAmount] = useState('100');
|
||||
const [caloriesUnit, setCaloriesUnit] = useState('千卡');
|
||||
const [caloriesUnit, setCaloriesUnit] = useState(t('createCustomFood.units.kcal'));
|
||||
const [calories, setCalories] = useState('100');
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const [protein, setProtein] = useState('0');
|
||||
@@ -93,7 +99,7 @@ export function CreateCustomFoodModal({
|
||||
if (visible) {
|
||||
setFoodName('');
|
||||
setDefaultAmount('100');
|
||||
setCaloriesUnit('千卡');
|
||||
setCaloriesUnit(t('createCustomFood.units.kcal'));
|
||||
setCalories('100');
|
||||
setImageUrl('');
|
||||
setProtein('0');
|
||||
@@ -102,16 +108,16 @@ export function CreateCustomFoodModal({
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 选择热量单位
|
||||
|
||||
|
||||
// 选择图片
|
||||
const handleSelectImage = async () => {
|
||||
try {
|
||||
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
|
||||
if (!libGranted) {
|
||||
Alert.alert('权限不足', '需要相册权限以选择照片');
|
||||
Alert.alert(
|
||||
t('createCustomFood.alerts.permissionDenied.title'),
|
||||
t('createCustomFood.alerts.permissionDenied.message')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,11 +143,17 @@ export function CreateCustomFoodModal({
|
||||
setImageUrl(url);
|
||||
} catch (e) {
|
||||
console.warn('上传照片失败', e);
|
||||
Alert.alert('上传失败', '照片上传失败,请重试');
|
||||
Alert.alert(
|
||||
t('createCustomFood.alerts.uploadFailed.title'),
|
||||
t('createCustomFood.alerts.uploadFailed.message')
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('发生错误', '选择照片失败,请重试');
|
||||
Alert.alert(
|
||||
t('createCustomFood.alerts.error.title'),
|
||||
t('createCustomFood.alerts.error.message')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,12 +163,18 @@ export function CreateCustomFoodModal({
|
||||
// 保存自定义食物
|
||||
const handleSave = () => {
|
||||
if (!foodName.trim()) {
|
||||
Alert.alert('提示', '请输入食物名称');
|
||||
Alert.alert(
|
||||
t('createCustomFood.alerts.validation.title'),
|
||||
t('createCustomFood.alerts.validation.nameRequired')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!calories.trim() || parseFloat(calories) <= 0) {
|
||||
Alert.alert('提示', '请输入有效的热量值');
|
||||
Alert.alert(
|
||||
t('createCustomFood.alerts.validation.title'),
|
||||
t('createCustomFood.alerts.validation.caloriesRequired')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,75 +193,99 @@ export function CreateCustomFoodModal({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isSaveDisabled = !foodName.trim() || !calories.trim();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="fade"
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
presentationStyle="overFullScreen"
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<BlurView intensity={20} tint="dark" style={styles.overlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardAvoidingView}
|
||||
>
|
||||
<View style={[
|
||||
styles.modalContainer,
|
||||
keyboardHeight > 0 && {
|
||||
height: screenHeight - keyboardHeight,
|
||||
maxHeight: screenHeight - keyboardHeight,
|
||||
}
|
||||
]}>
|
||||
<TouchableOpacity activeOpacity={1} onPress={onClose} style={styles.dismissArea} />
|
||||
<View
|
||||
style={[
|
||||
styles.modalContainer,
|
||||
keyboardHeight > 0 && {
|
||||
height: screenHeight - keyboardHeight - 60,
|
||||
maxHeight: screenHeight - keyboardHeight - 60,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.modalHeaderBar}>
|
||||
<View style={styles.dragIndicator} />
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: keyboardHeight > 0 ? 20 : 0
|
||||
paddingBottom: keyboardHeight > 0 ? 20 : 40,
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#333" />
|
||||
<TouchableOpacity onPress={onClose} style={styles.backButton} activeOpacity={0.7}>
|
||||
<Ionicons name="close-circle" size={32} color="#E2E8F0" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>创建自定义食物</Text>
|
||||
<Text style={styles.headerTitle}>{t('createCustomFood.title')}</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
(!foodName.trim() || !calories.trim()) && styles.saveButtonDisabled
|
||||
]}
|
||||
style={[styles.saveButton, isSaveDisabled && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={!foodName.trim() || !calories.trim()}
|
||||
disabled={isSaveDisabled}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={[
|
||||
styles.saveButtonText,
|
||||
(!foodName.trim() || !calories.trim()) && styles.saveButtonTextDisabled
|
||||
]}>保存</Text>
|
||||
<LinearGradient
|
||||
colors={isSaveDisabled ? CTA_DISABLED_GRADIENT : CTA_GRADIENT}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.saveButtonGradient}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>{t('createCustomFood.save')}</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 效果预览区域 */}
|
||||
<View style={styles.previewSection}>
|
||||
<Text style={styles.sectionTitle}>效果预览</Text>
|
||||
<View style={styles.previewCard}>
|
||||
<LinearGradient
|
||||
colors={['#ffffff', '#F8F9FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.previewHeader}>
|
||||
<Text style={styles.sectionTitle}>{t('createCustomFood.preview.title')}</Text>
|
||||
</View>
|
||||
<View style={styles.previewContent}>
|
||||
{imageUrl ? (
|
||||
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
|
||||
) : (
|
||||
<View style={styles.previewImagePlaceholder}>
|
||||
<Ionicons name="restaurant" size={20} color="#999" />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.imageWrapper}>
|
||||
{imageUrl ? (
|
||||
<Image style={styles.previewImage} source={{ uri: imageUrl }} />
|
||||
) : (
|
||||
<View style={styles.previewImagePlaceholder}>
|
||||
<Ionicons name="restaurant" size={24} color="#94A3B8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.previewInfo}>
|
||||
<Text style={styles.previewName}>
|
||||
{foodName || '食物名称'}
|
||||
</Text>
|
||||
<Text style={styles.previewCalories}>
|
||||
{actualCalories}{caloriesUnit}/{defaultAmount}g
|
||||
<Text style={styles.previewName} numberOfLines={1}>
|
||||
{foodName || t('createCustomFood.preview.defaultName')}
|
||||
</Text>
|
||||
<View style={styles.previewBadge}>
|
||||
<Ionicons name="flame" size={14} color="#F59E0B" />
|
||||
<Text style={styles.previewCalories}>
|
||||
{actualCalories} {caloriesUnit} / {defaultAmount}
|
||||
{t('createCustomFood.units.g')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -252,21 +294,21 @@ export function CreateCustomFoodModal({
|
||||
{/* 基本信息 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>基本信息</Text>
|
||||
<Text style={styles.sectionTitle}>{t('createCustomFood.basicInfo.title')}</Text>
|
||||
<Text style={styles.requiredIndicator}>*</Text>
|
||||
</View>
|
||||
<View style={styles.sectionCard}>
|
||||
{/* 食物名称和单位 */}
|
||||
{/* 食物名称 */}
|
||||
<View style={styles.inputRowContainer}>
|
||||
<Text style={styles.inputRowLabel}>食物名称</Text>
|
||||
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.name')}</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<View style={styles.numberInputContainer}>
|
||||
<View style={styles.modernInputContainer}>
|
||||
<TextInput
|
||||
style={styles.modernNumberInput}
|
||||
value={foodName}
|
||||
onChangeText={setFoodName}
|
||||
placeholder="例如,汉堡"
|
||||
placeholderTextColor="#A0A0A0"
|
||||
placeholder={t('createCustomFood.basicInfo.namePlaceholder')}
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -274,36 +316,36 @@ export function CreateCustomFoodModal({
|
||||
|
||||
{/* 默认数量 */}
|
||||
<View style={styles.inputRowContainer}>
|
||||
<Text style={styles.inputRowLabel}>默认数量</Text>
|
||||
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.defaultAmount')}</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<View style={styles.numberInputContainer}>
|
||||
<View style={styles.modernInputContainer}>
|
||||
<TextInput
|
||||
style={styles.modernNumberInput}
|
||||
value={defaultAmount}
|
||||
onChangeText={setDefaultAmount}
|
||||
keyboardType="numeric"
|
||||
placeholder="100"
|
||||
placeholderTextColor="#A0A0A0"
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
<Text style={styles.unitText}>g</Text>
|
||||
<Text style={styles.unitText}>{t('createCustomFood.units.g')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 食物热量 */}
|
||||
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
|
||||
<Text style={styles.inputRowLabel}>食物热量</Text>
|
||||
<Text style={styles.inputRowLabel}>{t('createCustomFood.basicInfo.calories')}</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<View style={styles.numberInputContainer}>
|
||||
<View style={styles.modernInputContainer}>
|
||||
<TextInput
|
||||
style={styles.modernNumberInput}
|
||||
value={calories}
|
||||
onChangeText={setCalories}
|
||||
keyboardType="numeric"
|
||||
placeholder="100"
|
||||
placeholderTextColor="#A0A0A0"
|
||||
placeholder="0"
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
<Text style={styles.unitText}>千卡</Text>
|
||||
<Text style={styles.unitText}>{t('createCustomFood.units.kcal')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -312,23 +354,26 @@ export function CreateCustomFoodModal({
|
||||
|
||||
{/* 可选信息 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>可选信息</Text>
|
||||
<Text style={styles.sectionTitle}>{t('createCustomFood.optionalInfo.title')}</Text>
|
||||
<View style={styles.sectionCard}>
|
||||
{/* 照片 */}
|
||||
<View style={styles.inputRowContainer}>
|
||||
<Text style={styles.inputRowLabel}>照片</Text>
|
||||
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.photo')}</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<TouchableOpacity
|
||||
style={styles.modernImageSelector}
|
||||
<TouchableOpacity
|
||||
style={styles.modernImageSelector}
|
||||
onPress={handleSelectImage}
|
||||
disabled={uploading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image style={styles.selectedImage} source={{ uri: imageUrl }} />
|
||||
) : (
|
||||
<View style={styles.modernImagePlaceholder}>
|
||||
<Ionicons name="camera" size={28} color="#A0A0A0" />
|
||||
<Text style={styles.imagePlaceholderText}>添加照片</Text>
|
||||
<Ionicons name="camera-outline" size={28} color="#94A3B8" />
|
||||
<Text style={styles.imagePlaceholderText}>
|
||||
{t('createCustomFood.optionalInfo.addPhoto')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{uploading && (
|
||||
@@ -342,54 +387,56 @@ export function CreateCustomFoodModal({
|
||||
|
||||
{/* 蛋白质 */}
|
||||
<View style={styles.inputRowContainer}>
|
||||
<Text style={styles.inputRowLabel}>蛋白质</Text>
|
||||
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.protein')}</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<View style={styles.numberInputContainer}>
|
||||
<View style={styles.modernInputContainer}>
|
||||
<TextInput
|
||||
style={styles.modernNumberInput}
|
||||
value={protein}
|
||||
onChangeText={setProtein}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor="#A0A0A0"
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
<Text style={styles.unitText}>克</Text>
|
||||
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 脂肪 */}
|
||||
<View style={styles.inputRowContainer}>
|
||||
<Text style={styles.inputRowLabel}>脂肪</Text>
|
||||
<Text style={styles.inputRowLabel}>{t('createCustomFood.optionalInfo.fat')}</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<View style={styles.numberInputContainer}>
|
||||
<View style={styles.modernInputContainer}>
|
||||
<TextInput
|
||||
style={styles.modernNumberInput}
|
||||
value={fat}
|
||||
onChangeText={setFat}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor="#A0A0A0"
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
<Text style={styles.unitText}>克</Text>
|
||||
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 碳水化合物 */}
|
||||
<View style={[styles.inputRowContainer, { marginBottom: 0 }]}>
|
||||
<Text style={styles.inputRowLabel}>碳水化合物</Text>
|
||||
<Text style={styles.inputRowLabel}>
|
||||
{t('createCustomFood.optionalInfo.carbohydrate')}
|
||||
</Text>
|
||||
<View style={styles.inputRowContent}>
|
||||
<View style={styles.numberInputContainer}>
|
||||
<View style={styles.modernInputContainer}>
|
||||
<TextInput
|
||||
style={styles.modernNumberInput}
|
||||
value={carbohydrate}
|
||||
onChangeText={setCarbohydrate}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor="#A0A0A0"
|
||||
placeholderTextColor="#94A3B8"
|
||||
/>
|
||||
<Text style={styles.unitText}>克</Text>
|
||||
<Text style={styles.unitText}>{t('createCustomFood.units.gram')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -398,7 +445,7 @@ export function CreateCustomFoodModal({
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</BlurView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -408,331 +455,272 @@ const { height: screenHeight } = Dimensions.get('window');
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
dismissArea: {
|
||||
flex: 1,
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFFFFF',
|
||||
marginTop: 50,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
backgroundColor: '#F1F5F9', // Slate 100
|
||||
borderTopLeftRadius: 32,
|
||||
borderTopRightRadius: 32,
|
||||
height: '90%',
|
||||
maxHeight: '90%',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -4,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modalHeaderBar: {
|
||||
width: '100%',
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
dragIndicator: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: '#CBD5E1',
|
||||
borderRadius: 2,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F1F5F9',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
backButton: {
|
||||
padding: 4,
|
||||
marginLeft: -8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
color: '#1E293B',
|
||||
textAlign: 'center',
|
||||
marginHorizontal: 20,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
saveButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonGradient: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 16,
|
||||
color: Colors.light.primary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveButtonTextDisabled: {
|
||||
color: Colors.light.textMuted,
|
||||
fontSize: 14,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
previewSection: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
previewCard: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 8,
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: 'rgba(30, 41, 59, 0.08)',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.4,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.6)',
|
||||
},
|
||||
previewHeader: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
previewContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageWrapper: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
previewImage: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 4,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F8FAFC',
|
||||
},
|
||||
previewImagePlaceholder: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#E5E5E5',
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F1F5F9',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
},
|
||||
previewInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
marginLeft: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
previewName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#333',
|
||||
marginBottom: 2,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#1E293B',
|
||||
marginBottom: 6,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
previewBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFBEB',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
gap: 4,
|
||||
},
|
||||
previewCalories: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontSize: 13,
|
||||
color: '#D97706',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
marginLeft: 8
|
||||
fontWeight: '700',
|
||||
color: '#64748B',
|
||||
fontFamily: 'AliBold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
requiredIndicator: {
|
||||
fontSize: 16,
|
||||
color: '#FF4444',
|
||||
fontSize: 14,
|
||||
color: '#EF4444',
|
||||
marginLeft: 4,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputRowGroup: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
inputRowItem: {
|
||||
flex: 1,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
modernTextInput: {
|
||||
flex: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
fontSize: 16,
|
||||
marginLeft: 20,
|
||||
color: '#333',
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
numberInputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
modernNumberInput: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
textAlign: 'right',
|
||||
},
|
||||
unitText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
paddingRight: 16,
|
||||
minWidth: 40,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modernSelectButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#E8E8E8',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
selectButtonText: {
|
||||
fontSize: 14,
|
||||
color: 'gray',
|
||||
fontWeight: '500',
|
||||
},
|
||||
modernImageSelector: {
|
||||
alignSelf: 'flex-end',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
selectedImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 16,
|
||||
},
|
||||
modernImagePlaceholder: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F8F8F8',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#E8E8E8',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
fontSize: 12,
|
||||
color: '#A0A0A0',
|
||||
marginTop: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
nutritionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
nutritionItem: {
|
||||
flex: 1,
|
||||
},
|
||||
// 保留旧样式以防兼容性问题
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E5',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
numberInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E5',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
backgroundColor: '#FFFFFF',
|
||||
textAlign: 'right',
|
||||
},
|
||||
inputWithUnit: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
inputUnit: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
minWidth: 30,
|
||||
},
|
||||
selectButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E5',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
imageSelector: {
|
||||
alignSelf: 'flex-end',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
imagePlaceholder: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#F0F0F0',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E5E5',
|
||||
},
|
||||
disclaimer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
disclaimerText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
lineHeight: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
sectionCard: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginTop: 8,
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 24,
|
||||
padding: 20,
|
||||
shadowColor: 'rgba(30, 41, 59, 0.05)',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
// 新增行布局样式
|
||||
inputRowContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputRowLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
fontWeight: '500',
|
||||
width: 80,
|
||||
fontSize: 15,
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
width: 90,
|
||||
marginRight: 12,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
inputRowContent: {
|
||||
flex: 1,
|
||||
},
|
||||
imageLoadingOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
modernInputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#F8FAFC',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
modernNumberInput: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
color: '#1E293B',
|
||||
textAlign: 'right',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
unitText: {
|
||||
fontSize: 14,
|
||||
color: '#94A3B8',
|
||||
paddingRight: 16,
|
||||
minWidth: 40,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
modernImageSelector: {
|
||||
alignSelf: 'flex-end',
|
||||
borderRadius: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
selectedImage: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 20,
|
||||
},
|
||||
modernImagePlaceholder: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F8FAFC',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E2E8F0',
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
fontSize: 11,
|
||||
color: '#94A3B8',
|
||||
marginTop: 4,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
imageLoadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: 20,
|
||||
},
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
// 睡眠详情数据类型
|
||||
export type SleepDetailData = {
|
||||
@@ -41,15 +42,22 @@ const SleepGradeCard = ({
|
||||
range: string;
|
||||
isActive?: boolean;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
const getGradeColor = (grade: string) => {
|
||||
switch (grade) {
|
||||
case '低': case '较差': return { bg: '#FECACA', text: '#DC2626' };
|
||||
case '正常': case '一般': return { bg: '#D1FAE5', text: '#065F46' };
|
||||
case '良好': return { bg: '#D1FAE5', text: '#065F46' };
|
||||
case '优秀': return { bg: '#FEF3C7', text: '#92400E' };
|
||||
case t('sleepDetail.sleepGrades.low'):
|
||||
case t('sleepDetail.sleepGrades.poor'):
|
||||
return { bg: '#FECACA', text: '#DC2626' };
|
||||
case t('sleepDetail.sleepGrades.normal'):
|
||||
case t('sleepDetail.sleepGrades.fair'):
|
||||
return { bg: '#D1FAE5', text: '#065F46' };
|
||||
case t('sleepDetail.sleepGrades.good'):
|
||||
return { bg: '#D1FAE5', text: '#065F46' };
|
||||
case t('sleepDetail.sleepGrades.excellent'):
|
||||
return { bg: '#FEF3C7', text: '#92400E' };
|
||||
default: return { bg: colorTokens.pageBackgroundEmphasis, text: colorTokens.textSecondary };
|
||||
}
|
||||
};
|
||||
@@ -97,6 +105,7 @@ export const InfoModal = ({
|
||||
type: 'sleep-time' | 'sleep-quality';
|
||||
sleepData: SleepDetailData;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const slideAnim = useState(new Animated.Value(0))[0];
|
||||
@@ -153,26 +162,26 @@ export const InfoModal = ({
|
||||
const currentSleepQualityGrade = getSleepQualityGrade(sleepData.sleepQualityPercentage || 94); // 默认94%
|
||||
|
||||
const sleepTimeGrades = [
|
||||
{ icon: 'alert-circle-outline', grade: '低', range: '< 6h', isActive: currentSleepTimeGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: '正常', range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: '良好', range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
|
||||
{ icon: 'star', grade: '优秀', range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
|
||||
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.low'), range: '< 6h', isActive: currentSleepTimeGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.normal'), range: '6h - 7h or > 9h', isActive: currentSleepTimeGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '7h - 8h', isActive: currentSleepTimeGrade === 2 },
|
||||
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '8h - 9h', isActive: currentSleepTimeGrade === 3 },
|
||||
];
|
||||
|
||||
const sleepQualityGrades = [
|
||||
{ icon: 'alert-circle-outline', grade: '较差', range: '< 55%', isActive: currentSleepQualityGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: '一般', range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: '良好', range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
|
||||
{ icon: 'star', grade: '优秀', range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
|
||||
{ icon: 'alert-circle-outline', grade: t('sleepDetail.sleepGrades.poor'), range: '< 55%', isActive: currentSleepQualityGrade === 0 },
|
||||
{ icon: 'checkmark-circle-outline', grade: t('sleepDetail.sleepGrades.fair'), range: '55% - 69%', isActive: currentSleepQualityGrade === 1 },
|
||||
{ icon: 'checkmark-circle', grade: t('sleepDetail.sleepGrades.good'), range: '70% - 84%', isActive: currentSleepQualityGrade === 2 },
|
||||
{ icon: 'star', grade: t('sleepDetail.sleepGrades.excellent'), range: '85% - 100%', isActive: currentSleepQualityGrade === 3 },
|
||||
];
|
||||
|
||||
const currentGrades = type === 'sleep-time' ? sleepTimeGrades : sleepQualityGrades;
|
||||
|
||||
const getDescription = () => {
|
||||
if (type === 'sleep-time') {
|
||||
return '睡眠最重要 - 它占据了你睡眠得分的一半以上。长时间的睡眠可以减少睡眠债务,但是规律的睡眠时间对于高质量的休息至关重要。';
|
||||
return t('sleepDetail.sleepTimeDescription');
|
||||
} else {
|
||||
return '睡眠质量综合评估您的睡眠效率、深度睡眠时长、REM睡眠比例等多个指标。高质量的睡眠不仅仅取决于时长,还包括睡眠的连续性和各睡眠阶段的平衡。';
|
||||
return t('sleepDetail.sleepQualityDescription');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,36 +1,47 @@
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Rect, Text as SvgText } from 'react-native-svg';
|
||||
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop, Text as SvgText } from 'react-native-svg';
|
||||
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
export type SleepStageTimelineProps = {
|
||||
sleepSamples: SleepSample[];
|
||||
bedtime: string;
|
||||
wakeupTime: string;
|
||||
onInfoPress?: () => void;
|
||||
hideHeader?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
export const SleepStageTimeline = ({
|
||||
sleepSamples,
|
||||
bedtime,
|
||||
wakeupTime,
|
||||
onInfoPress
|
||||
onInfoPress,
|
||||
hideHeader = false,
|
||||
style
|
||||
}: SleepStageTimelineProps) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
|
||||
// 图表尺寸参数
|
||||
const containerWidth = 320;
|
||||
const chartPadding = 25; // 左右边距,为时间标签预留空间
|
||||
// 图表尺寸参数 - 更宽更现代的设计
|
||||
const containerWidth = SCREEN_WIDTH - 64; // 留出左右边距
|
||||
const chartPadding = 24; // 增加左右内边距,避免时间轴和标签被截断
|
||||
const chartWidth = containerWidth - chartPadding * 2;
|
||||
const chartHeight = 80;
|
||||
const timelineHeight = 32;
|
||||
const timelineY = 24;
|
||||
const timeScaleY = timelineY + timelineHeight + 16;
|
||||
const chartHeight = 140; // 增加高度以容纳更高的条形图
|
||||
const timelineHeight = 48; // 更高的条形图
|
||||
const timelineY = 16;
|
||||
const timeScaleY = timelineY + timelineHeight + 24;
|
||||
|
||||
// 计算时间范围和刻度
|
||||
const { timelineData, timeLabels } = useMemo(() => {
|
||||
@@ -56,7 +67,7 @@ export const SleepStageTimeline = ({
|
||||
const duration = sampleEnd.diff(sampleStart, 'minute');
|
||||
|
||||
const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding;
|
||||
const width = Math.max(2, (duration / totalMinutes) * chartWidth);
|
||||
const width = Math.max(3, (duration / totalMinutes) * chartWidth);
|
||||
|
||||
return {
|
||||
x,
|
||||
@@ -66,29 +77,27 @@ export const SleepStageTimeline = ({
|
||||
};
|
||||
});
|
||||
|
||||
// 智能生成时间标签,避免重合
|
||||
// 智能生成时间标签
|
||||
const labels = [];
|
||||
const minLabelSpacing = 50; // 最小标签间距(像素)
|
||||
const minLabelSpacing = 60;
|
||||
|
||||
// 总是显示起始时间
|
||||
// 起始时间标签
|
||||
labels.push({
|
||||
time: startTime.format('HH:mm'),
|
||||
x: chartPadding
|
||||
});
|
||||
|
||||
// 根据睡眠总时长动态调整时间间隔
|
||||
const sleepDurationHours = totalMinutes / 60;
|
||||
let timeStepMinutes;
|
||||
|
||||
if (sleepDurationHours <= 4) {
|
||||
timeStepMinutes = 60; // 1小时间隔
|
||||
timeStepMinutes = 60;
|
||||
} else if (sleepDurationHours <= 8) {
|
||||
timeStepMinutes = 120; // 2小时间隔
|
||||
timeStepMinutes = 120;
|
||||
} else {
|
||||
timeStepMinutes = 180; // 3小时间隔
|
||||
timeStepMinutes = 180;
|
||||
}
|
||||
|
||||
// 添加中间时间标签,确保不重合
|
||||
let currentTime = startTime;
|
||||
let stepCount = 1;
|
||||
|
||||
@@ -96,7 +105,6 @@ export const SleepStageTimeline = ({
|
||||
const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute');
|
||||
const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding;
|
||||
|
||||
// 检查与前一个标签的间距
|
||||
const lastLabel = labels[labels.length - 1];
|
||||
if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) {
|
||||
labels.push({
|
||||
@@ -108,7 +116,7 @@ export const SleepStageTimeline = ({
|
||||
stepCount++;
|
||||
}
|
||||
|
||||
// 总是显示结束时间,但要确保与前一个标签有足够间距
|
||||
// 结束时间标签
|
||||
const endX = containerWidth - chartPadding;
|
||||
const lastLabel = labels[labels.length - 1];
|
||||
if (endX - lastLabel.x >= minLabelSpacing) {
|
||||
@@ -117,7 +125,6 @@ export const SleepStageTimeline = ({
|
||||
x: endX
|
||||
});
|
||||
} else {
|
||||
// 如果空间不够,替换最后一个标签为结束时间
|
||||
labels[labels.length - 1] = {
|
||||
time: endTime.format('HH:mm'),
|
||||
x: endX
|
||||
@@ -130,18 +137,22 @@ export const SleepStageTimeline = ({
|
||||
// 如果没有数据,显示空状态
|
||||
if (timelineData.length === 0) {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }, style]}>
|
||||
{!hideHeader && (
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>
|
||||
{t('sleepDetail.sleepStages')}
|
||||
</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
|
||||
暂无睡眠阶段数据
|
||||
{t('sleepDetail.noData')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -149,67 +160,119 @@ export const SleepStageTimeline = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
|
||||
<View style={[styles.container, { backgroundColor: 'transparent' }, style]}>
|
||||
{/* 标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>睡眠阶段图</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{!hideHeader && (
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colorTokens.text }]}>
|
||||
{t('sleepDetail.sleepStages')}
|
||||
</Text>
|
||||
{onInfoPress && (
|
||||
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
|
||||
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 睡眠时间范围 */}
|
||||
{/* 睡眠时间范围 - 更简洁的设计 */}
|
||||
<View style={styles.timeRange}>
|
||||
<View style={styles.timePoint}>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>入睡</Text>
|
||||
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
||||
<Ionicons name="moon" size={16} color="#8B9DC3" style={{ marginBottom: 4 }} />
|
||||
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
|
||||
{formatTime(bedtime)}
|
||||
</Text>
|
||||
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
|
||||
{t('sleepDetail.infoModalTitles.sleepTime')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.timePoint}>
|
||||
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}>起床</Text>
|
||||
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
|
||||
<Ionicons name="sunny" size={16} color="#F59E0B" style={{ marginBottom: 4 }} />
|
||||
<Text style={[styles.timeValue, { color: '#1c1f3a' }]}>
|
||||
{formatTime(wakeupTime)}
|
||||
</Text>
|
||||
<Text style={[styles.timeLabel, { color: '#8B9DC3' }]}>
|
||||
{t('sleepDetail.sleepDuration')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* SVG 图表 */}
|
||||
{/* SVG 图表 - iOS 健康风格 */}
|
||||
<View style={styles.chartContainer}>
|
||||
{/* 背景轨道 */}
|
||||
<View style={[styles.trackBackground, {
|
||||
left: chartPadding,
|
||||
right: chartPadding,
|
||||
width: chartWidth
|
||||
}]} />
|
||||
|
||||
<Svg width={containerWidth} height={chartHeight}>
|
||||
{/* 绘制睡眠阶段条形图 */}
|
||||
{timelineData.map((segment, index) => (
|
||||
<Rect
|
||||
key={index}
|
||||
x={segment.x}
|
||||
y={timelineY}
|
||||
width={segment.width}
|
||||
height={timelineHeight}
|
||||
fill={segment.color}
|
||||
rx={2}
|
||||
/>
|
||||
))}
|
||||
<Defs>
|
||||
{/* 为每种睡眠阶段定义渐变 */}
|
||||
<SvgLinearGradient id="gradDeep" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor="#60A5FA" stopOpacity="1" />
|
||||
<Stop offset="1" stopColor="#3B82F6" stopOpacity="0.85" />
|
||||
</SvgLinearGradient>
|
||||
<SvgLinearGradient id="gradCore" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor="#A78BFA" stopOpacity="1" />
|
||||
<Stop offset="1" stopColor="#8B5CF6" stopOpacity="0.85" />
|
||||
</SvgLinearGradient>
|
||||
<SvgLinearGradient id="gradREM" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
|
||||
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
|
||||
</SvgLinearGradient>
|
||||
<SvgLinearGradient id="gradAwake" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor="#FCD34D" stopOpacity="1" />
|
||||
<Stop offset="1" stopColor="#F59E0B" stopOpacity="0.85" />
|
||||
</SvgLinearGradient>
|
||||
<SvgLinearGradient id="gradAsleep" x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor="#F472B6" stopOpacity="1" />
|
||||
<Stop offset="1" stopColor="#EC4899" stopOpacity="0.85" />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
|
||||
{/* 绘制时间刻度标签 */}
|
||||
{/* 绘制睡眠阶段条形图 - 使用渐变和圆角 */}
|
||||
{timelineData.map((segment, index) => {
|
||||
const gradientId =
|
||||
segment.stage === SleepStage.Deep ? 'gradDeep' :
|
||||
segment.stage === SleepStage.Core ? 'gradCore' :
|
||||
segment.stage === SleepStage.REM || segment.stage === SleepStage.Asleep ? 'gradREM' :
|
||||
segment.stage === SleepStage.Awake ? 'gradAwake' : 'gradAsleep';
|
||||
|
||||
return (
|
||||
<Rect
|
||||
key={index}
|
||||
x={segment.x}
|
||||
y={timelineY}
|
||||
width={segment.width}
|
||||
height={timelineHeight}
|
||||
fill={`url(#${gradientId})`}
|
||||
rx={8}
|
||||
opacity={0.95}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 绘制时间刻度标签 - 更细腻的设计 */}
|
||||
{timeLabels.map((label, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{/* 刻度线 */}
|
||||
<Rect
|
||||
x={label.x - 0.5}
|
||||
y={timelineY + timelineHeight}
|
||||
y={timelineY + timelineHeight + 4}
|
||||
width={1}
|
||||
height={6}
|
||||
fill={colorTokens.border}
|
||||
height={4}
|
||||
fill="#D1D5DB"
|
||||
opacity={0.4}
|
||||
/>
|
||||
{/* 时间标签 */}
|
||||
<SvgText
|
||||
x={label.x}
|
||||
y={timeScaleY}
|
||||
fontSize={11}
|
||||
fill={colorTokens.textSecondary}
|
||||
fill="#8B9DC3"
|
||||
textAnchor="middle"
|
||||
fontWeight="500"
|
||||
>
|
||||
{label.time}
|
||||
</SvgText>
|
||||
@@ -218,27 +281,43 @@ export const SleepStageTimeline = ({
|
||||
</Svg>
|
||||
</View>
|
||||
|
||||
{/* 图例 */}
|
||||
{/* 图例 - iOS 风格的标签 */}
|
||||
<View style={styles.legend}>
|
||||
<View style={styles.legendRow}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
|
||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>深度睡眠</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
|
||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>核心睡眠</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<LinearGradient
|
||||
colors={['#60A5FA', '#3B82F6']}
|
||||
style={styles.legendPill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.legendText}>{t('sleepDetail.deep')}</Text>
|
||||
</View>
|
||||
<View style={styles.legendRow}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
|
||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>快速眼动</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
|
||||
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}>清醒时间</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<LinearGradient
|
||||
colors={['#A78BFA', '#8B5CF6']}
|
||||
style={styles.legendPill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.legendText}>{t('sleepDetail.core')}</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<LinearGradient
|
||||
colors={['#F472B6', '#EC4899']}
|
||||
style={styles.legendPill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.legendText}>{t('sleepDetail.rem')}</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<LinearGradient
|
||||
colors={['#FCD34D', '#F59E0B']}
|
||||
style={styles.legendPill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.legendText}>{t('sleepDetail.awake')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -248,14 +327,8 @@ export const SleepStageTimeline = ({
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 24,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginHorizontal: 4,
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@@ -266,31 +339,44 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
infoButton: {
|
||||
padding: 4,
|
||||
},
|
||||
timeRange: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
justifyContent: 'space-around',
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
timePoint: {
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
timeLabel: {
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
timeValue: {
|
||||
fontSize: 16,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.2,
|
||||
fontFamily: 'AliBold',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
chartContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
},
|
||||
trackBackground: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
height: 48,
|
||||
backgroundColor: '#F0F2F9',
|
||||
borderRadius: 24,
|
||||
opacity: 0.5,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
@@ -299,27 +385,29 @@ const styles = StyleSheet.create({
|
||||
emptyText: {
|
||||
fontSize: 14,
|
||||
fontStyle: 'italic',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
legend: {
|
||||
gap: 8,
|
||||
},
|
||||
legendRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
legendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
legendDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
legendPill: {
|
||||
width: 20,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#6B7280',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useI18n } from '@/hooks/useI18n';
|
||||
|
||||
// Sleep Stages Info Modal 组件
|
||||
export const SleepStagesInfoModal = ({
|
||||
@@ -22,6 +23,7 @@ export const SleepStagesInfoModal = ({
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
const colorTokens = Colors[theme];
|
||||
const slideAnim = useState(new Animated.Value(0))[0];
|
||||
@@ -82,7 +84,7 @@ export const SleepStagesInfoModal = ({
|
||||
|
||||
<View style={styles.sleepStagesModalHeader}>
|
||||
<Text style={[styles.sleepStagesModalTitle, { color: colorTokens.text }]}>
|
||||
了解你的睡眠阶段
|
||||
{t('sleepDetail.sleepStagesInfo.title')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.infoModalCloseButton}>
|
||||
<Ionicons name="close" size={24} color={colorTokens.textSecondary} />
|
||||
@@ -97,7 +99,7 @@ export const SleepStagesInfoModal = ({
|
||||
scrollEnabled={true}
|
||||
>
|
||||
<Text style={[styles.sleepStagesDescription, { color: colorTokens.textSecondary }]}>
|
||||
人们对睡眠阶段和睡眠质量有许多误解。有些人可能需要更多深度睡眠,其他人则不然。科学家和医生仍在探索不同睡眠阶段的作用及其对身体的影响。通过跟踪睡眠阶段并留意每天清晨的感受,你或许能深入了解自己的睡眠。
|
||||
{t('sleepDetail.sleepStagesInfo.description')}
|
||||
</Text>
|
||||
|
||||
{/* 清醒时间 */}
|
||||
@@ -105,11 +107,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<View style={[styles.sleepStageDot, { backgroundColor: '#F59E0B' }]} />
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>清醒时间</Text>
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.awake.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
一次睡眠期间,你可能会醒来几次。偶尔醒来很正常。可能你会立刻再次入睡,并不记得曾在夜间醒来。
|
||||
{t('sleepDetail.sleepStagesInfo.awake.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -118,11 +120,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<View style={[styles.sleepStageDot, { backgroundColor: '#EC4899' }]} />
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>快速动眼睡眠</Text>
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.rem.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
这一睡眠阶段可能对学习和记忆产生一定影响。在此阶段,你的肌肉最为放松,眼球也会快速左右移动。这也是你大多数梦境出现的阶段。
|
||||
{t('sleepDetail.sleepStagesInfo.rem.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -131,11 +133,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<View style={[styles.sleepStageDot, { backgroundColor: '#8B5CF6' }]} />
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>核心睡眠</Text>
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.core.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
这一阶段有时也称为浅睡期,与其他阶段一样重要。此阶段通常占据你每晚大部分的睡眠时间。对于认知至关重要的脑电波会在这一阶段产生。
|
||||
{t('sleepDetail.sleepStagesInfo.core.description')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -144,11 +146,11 @@ export const SleepStagesInfoModal = ({
|
||||
<View style={[styles.sleepStageInfoHeader, { borderBottomColor: colorTokens.border }]}>
|
||||
<View style={styles.sleepStageInfoTitleContainer}>
|
||||
<View style={[styles.sleepStageDot, { backgroundColor: '#3B82F6' }]} />
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>深度睡眠</Text>
|
||||
<Text style={[styles.sleepStageInfoTitle, { color: colorTokens.text }]}>{t('sleepDetail.sleepStagesInfo.deep.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.sleepStageInfoContent, { color: colorTokens.textSecondary }]}>
|
||||
因为脑电波的特征,这一阶段也称为慢波睡眠。在此阶段,身体组织得到修复,并释放重要荷尔蒙。它通常出现在睡眠的前半段,且持续时间较长。深度睡眠期间,身体非常放松,因此相较于其他阶段,你可能更难在此阶段醒来。
|
||||
{t('sleepDetail.sleepStagesInfo.deep.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -207,6 +207,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
marginBottom: 16,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
measurementsContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -221,6 +222,7 @@ const styles = StyleSheet.create({
|
||||
color: '#888',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
valueContainer: {
|
||||
backgroundColor: '#F5F5F7',
|
||||
@@ -236,6 +238,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
valueContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -81,6 +82,7 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
unit: {
|
||||
fontSize: 12,
|
||||
@@ -88,6 +90,7 @@ const styles = StyleSheet.create({
|
||||
marginLeft: 4,
|
||||
marginBottom: 2,
|
||||
fontWeight: '500',
|
||||
fontFamily: 'AliRegular',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fetchOxygenSaturation } from '@/utils/health';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { ensureHealthPermissions, fetchOxygenSaturation } from '@/utils/health';
|
||||
import { HealthKitUtils } from '@/utils/healthKit';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import HealthDataCard from './HealthDataCard';
|
||||
|
||||
@@ -15,42 +16,52 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
selectedDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isFocused = useIsFocused();
|
||||
const [oxygenSaturation, setOxygenSaturation] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
// 获取血氧饱和度数据 - 在页面聚焦、日期变化时触发
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const loadOxygenSaturationData = async () => {
|
||||
const dateToUse = selectedDate || new Date();
|
||||
useEffect(() => {
|
||||
const loadOxygenSaturationData = async () => {
|
||||
const dateToUse = selectedDate || new Date();
|
||||
|
||||
// 防止重复请求
|
||||
if (loadingRef.current) return;
|
||||
if (!isFocused) return;
|
||||
if (!HealthKitUtils.isAvailable()) {
|
||||
setOxygenSaturation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
// 防止重复请求
|
||||
if (loadingRef.current) return;
|
||||
|
||||
const options = {
|
||||
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
|
||||
};
|
||||
try {
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
const data = await fetchOxygenSaturation(options);
|
||||
setOxygenSaturation(data);
|
||||
} catch (error) {
|
||||
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
|
||||
const hasPermission = await ensureHealthPermissions();
|
||||
if (!hasPermission) {
|
||||
setOxygenSaturation(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loadOxygenSaturationData();
|
||||
}, [selectedDate])
|
||||
);
|
||||
const options = {
|
||||
startDate: dayjs(dateToUse).startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs(dateToUse).endOf('day').toDate().toISOString()
|
||||
};
|
||||
|
||||
const data = await fetchOxygenSaturation(options);
|
||||
setOxygenSaturation(data);
|
||||
} catch (error) {
|
||||
console.error('OxygenSaturationCard: Failed to get blood oxygen data:', error);
|
||||
setOxygenSaturation(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
loadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
loadOxygenSaturationData();
|
||||
}, [isFocused, selectedDate]);
|
||||
|
||||
return (
|
||||
<HealthDataCard
|
||||
@@ -62,4 +73,4 @@ const OxygenSaturationCard: React.FC<OxygenSaturationCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default OxygenSaturationCard;
|
||||
export default OxygenSaturationCard;
|
||||
|
||||
@@ -127,12 +127,14 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
sleepValue: {
|
||||
fontSize: 16,
|
||||
color: '#1E40AF',
|
||||
fontWeight: '700',
|
||||
marginTop: 8,
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -30,6 +30,9 @@ const MAPPING = {
|
||||
'info.circle': 'info',
|
||||
'magnifyingglass': 'search',
|
||||
'xmark': 'close',
|
||||
'chevron.left': 'chevron-left',
|
||||
'sparkles': 'auto-awesome',
|
||||
'arrow.clockwise': 'refresh',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
454
components/ui/MedicationAiSummaryInfoSheet.tsx
Normal file
454
components/ui/MedicationAiSummaryInfoSheet.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { Image } from 'expo-image';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
BackHandler,
|
||||
Dimensions,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View
|
||||
} from 'react-native';
|
||||
import ImageViewing from 'react-native-image-viewing';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useI18n } from '../../hooks/useI18n';
|
||||
import { triggerLightHaptic } from '../../utils/haptics';
|
||||
|
||||
const { height: screenHeight } = Dimensions.get('window');
|
||||
|
||||
interface MedicationAiSummaryInfoSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 用药总结介绍弹窗组件
|
||||
* 用于展示 AI 用药总结功能的介绍和说明
|
||||
*/
|
||||
export function MedicationAiSummaryInfoSheet({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading = false,
|
||||
}: MedicationAiSummaryInfoSheetProps) {
|
||||
const { t } = useI18n();
|
||||
const insets = useSafeAreaInsets();
|
||||
const translateY = useRef(new Animated.Value(screenHeight)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
|
||||
// 预览图片 - 直接使用 require 资源
|
||||
const imageSource = require('@/assets/images/medicine/medicine-ai-summary.png');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
translateY.setValue(screenHeight);
|
||||
backdropOpacity.setValue(0);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(translateY, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
bounciness: 6,
|
||||
speed: 12,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: screenHeight,
|
||||
duration: 240,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
translateY.setValue(screenHeight);
|
||||
backdropOpacity.setValue(0);
|
||||
});
|
||||
}
|
||||
}, [visible, backdropOpacity, translateY]);
|
||||
|
||||
// 处理Android返回键关闭图片预览
|
||||
useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showImagePreview) {
|
||||
setShowImagePreview(false);
|
||||
return true; // 阻止默认返回行为
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}, [showImagePreview]);
|
||||
|
||||
// 处理图片预览
|
||||
const handleImagePreview = useCallback(() => {
|
||||
triggerLightHaptic();
|
||||
setShowImagePreview(true);
|
||||
}, []);
|
||||
|
||||
const handleClose = () => {
|
||||
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch((error) => {
|
||||
console.warn('[AI_SUMMARY] Haptic feedback failed:', error);
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (loading) return;
|
||||
// 安全地执行触觉反馈,避免因触觉反馈失败导致页面卡顿
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch((error) => {
|
||||
console.warn('[AI_SUMMARY] Haptic feedback failed:', error);
|
||||
});
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={handleClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.backdrop,
|
||||
{
|
||||
opacity: backdropOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity style={StyleSheet.absoluteFillObject} activeOpacity={1} onPress={handleClose} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{
|
||||
transform: [{ translateY }],
|
||||
paddingBottom: Math.max(insets.bottom, 20),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
|
||||
{/* 图标和标题 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="sparkles" size={24} color="#8B5CF6" />
|
||||
</View>
|
||||
<Text style={styles.title}>{t('medications.aiSummaryInfo.title')}</Text>
|
||||
</View>
|
||||
|
||||
{/* 介绍图片区域 */}
|
||||
<View style={styles.imageContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/medicine/medicine-ai-summary.png')}
|
||||
style={styles.introImage}
|
||||
contentFit='contain'
|
||||
/>
|
||||
{/* 右上角查看大图提示按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.viewImageButton}
|
||||
onPress={handleImagePreview}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.glassViewButton}
|
||||
glassEffectStyle="clear"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Ionicons name="expand-outline" size={16} color="#333" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.glassViewButton, styles.fallbackViewButton]}>
|
||||
<Ionicons name="expand-outline" size={16} color="#333" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 功能介绍内容 */}
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Ionicons name="analytics" size={20} color="#8B5CF6" />
|
||||
</View>
|
||||
<View style={styles.featureContent}>
|
||||
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.intelligent.title')}</Text>
|
||||
<Text style={styles.featureDescription}>
|
||||
{t('medications.aiSummaryInfo.features.intelligent.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Ionicons name="time" size={20} color="#8B5CF6" />
|
||||
</View>
|
||||
<View style={styles.featureContent}>
|
||||
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.tracking.title')}</Text>
|
||||
<Text style={styles.featureDescription}>
|
||||
{t('medications.aiSummaryInfo.features.tracking.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Ionicons name="shield-checkmark" size={20} color="#8B5CF6" />
|
||||
</View>
|
||||
<View style={styles.featureContent}>
|
||||
<Text style={styles.featureTitle}>{t('medications.aiSummaryInfo.features.professional.title')}</Text>
|
||||
<Text style={styles.featureDescription}>
|
||||
{t('medications.aiSummaryInfo.features.professional.description')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 确认按钮 - 支持 Liquid Glass */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.confirmButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(139, 92, 246, 0.8)"
|
||||
isInteractive={true}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="arrow-forward" size={20} color="#fff" />
|
||||
<Text style={styles.confirmText}>{t('medications.aiSummaryInfo.confirmButton')}</Text>
|
||||
</>
|
||||
)}
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.confirmButton, styles.fallbackButton]}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="arrow-forward" size={20} color="#fff" />
|
||||
<Text style={styles.confirmText}>{t('medications.aiSummaryInfo.confirmButton')}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 图片预览 */}
|
||||
<ImageViewing
|
||||
images={[imageSource]}
|
||||
imageIndex={0}
|
||||
visible={showImagePreview}
|
||||
onRequestClose={() => setShowImagePreview(false)}
|
||||
swipeToCloseEnabled={true}
|
||||
doubleTapToZoomEnabled={true}
|
||||
FooterComponent={() => (
|
||||
<View style={styles.imageViewerFooter}>
|
||||
<TouchableOpacity
|
||||
style={styles.imageViewerFooterButton}
|
||||
onPress={() => setShowImagePreview(false)}
|
||||
>
|
||||
<Text style={styles.imageViewerFooterButtonText}>{t('medications.detail.imageViewer.close')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.45)',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 28,
|
||||
borderTopRightRadius: 28,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
elevation: 16,
|
||||
gap: 20,
|
||||
},
|
||||
handle: {
|
||||
width: 50,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#E5E7EB',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#F3E8FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
height: 380,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#F9FAFB',
|
||||
},
|
||||
introImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 16,
|
||||
},
|
||||
contentContainer: {
|
||||
gap: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
featureIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#F3E8FF',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
featureContent: {
|
||||
flex: 1,
|
||||
},
|
||||
featureTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#111827',
|
||||
marginBottom: 4,
|
||||
},
|
||||
featureDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
color: '#6B7280',
|
||||
},
|
||||
actions: {
|
||||
marginTop: 8,
|
||||
},
|
||||
confirmButton: {
|
||||
height: 56,
|
||||
borderRadius: 18,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
overflow: 'hidden', // 保证玻璃边界圆角效果
|
||||
},
|
||||
fallbackButton: {
|
||||
backgroundColor: '#8B5CF6',
|
||||
shadowColor: 'rgba(139, 92, 246, 0.45)',
|
||||
shadowOffset: { width: 0, height: 10 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 20,
|
||||
elevation: 6,
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
// 图片预览相关样式
|
||||
viewImageButton: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
zIndex: 1,
|
||||
},
|
||||
glassViewButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fallbackViewButton: {
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
},
|
||||
imageViewerFooter: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
alignItems: 'center',
|
||||
zIndex: 1,
|
||||
},
|
||||
imageViewerFooterButton: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
imageViewerFooterButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user