feat: 更新 CLAUDE.md 文件及多个组件以优化用户体验和功能
- 更新 CLAUDE.md 文件,重构架构部分,增加认证和数据层的描述 - 在 GoalsScreen 中新增目标模板选择功能,支持用户选择和创建目标 - 在 CreateGoalModal 中添加初始数据支持,优化目标创建体验 - 新增 GoalTemplateModal 组件,提供目标模板选择界面 - 更新 NotificationHelpers,支持构建深度链接以便于导航 - 在 CoachScreen 中处理路由参数,增强用户交互体验 - 更新多个组件的样式和逻辑,提升整体用户体验 - 删除不再使用的中文回复规则文档
This commit is contained in:
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
inclusion: always
|
|
||||||
---
|
|
||||||
|
|
||||||
# 中文回复规则
|
|
||||||
|
|
||||||
请始终使用中文进行回复和编写文档。包括:
|
|
||||||
|
|
||||||
- 所有对话回复都使用中文
|
|
||||||
- 代码注释使用中文
|
|
||||||
- 文档和说明使用中文
|
|
||||||
- 错误信息和提示使用中文
|
|
||||||
- 变量名和函数名可以使用英文,但注释和文档说明必须是中文
|
|
||||||
|
|
||||||
这个规则适用于所有交互,除非用户明确要求使用其他语言。
|
|
||||||
63
CLAUDE.md
63
CLAUDE.md
@@ -11,35 +11,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- **Reset project**: `npm run reset-project`
|
- **Reset project**: `npm run reset-project`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
- **Framework**: React Native (Expo) with TypeScript.
|
- **Framework**: React Native (Expo) with TypeScript using Expo Router for file-based navigation
|
||||||
- **Navigation**: Expo Router for file-based routing (`app/` directory).
|
- **State Management**: Redux Toolkit with domain-specific slices (`store/`) and typed hooks (`hooks/redux.ts`)
|
||||||
- **State Management**: Redux Toolkit with slices for different domains (user, training plans, workouts, challenges, etc.).
|
- **Authentication**: Custom auth guard system with `useAuthGuard` hook for protected navigation
|
||||||
- **UI**: Themed components (`ThemedText`, `ThemedView`) and reusable UI elements (`Collapsible`, `ParallaxScrollView`).
|
- **Navigation**:
|
||||||
- **API Layer**: Service files for communicating with backend APIs (`services/` directory).
|
- File-based routing in `app/` directory with nested layouts
|
||||||
- **Data Persistence**: AsyncStorage for local data storage.
|
- Tab-based navigation with custom styling and haptic feedback
|
||||||
- **Platform-Specific**: Android (`android/`) and iOS (`ios/`) configurations with native modules.
|
- Route constants defined in `constants/Routes.ts`
|
||||||
- **Hooks**: Custom hooks for color scheme (`useColorScheme`), theme management (`useThemeColor`), and Redux integration (`useRedux`).
|
- **UI System**:
|
||||||
- **Dependencies**: React Navigation for tab-based navigation, Expo modules for native features (haptics, blur, image picking, etc.), and third-party libraries for specific functionality.
|
- Themed components (`ThemedText`, `ThemedView`) with color scheme support
|
||||||
|
- Custom icon system with `IconSymbol` component for iOS symbols
|
||||||
|
- Reusable UI components in `components/ui/`
|
||||||
|
- **Data Layer**:
|
||||||
|
- API services in `services/` directory with centralized API client
|
||||||
|
- AsyncStorage for local persistence
|
||||||
|
- Background task management for sync operations
|
||||||
|
- **Native Integration**:
|
||||||
|
- Health data integration with HealthKit
|
||||||
|
- Apple Authentication
|
||||||
|
- Camera and photo library access for posture assessment
|
||||||
|
- Push notifications with background task support
|
||||||
|
- Haptic feedback integration
|
||||||
|
|
||||||
## Key Features
|
## Key Architecture Patterns
|
||||||
- **Authentication**: Login flow with Apple authentication support.
|
- **Redux Auto-sync**: Listener middleware automatically syncs checkin data changes to backend
|
||||||
- **Training Plans**: Creation and management of personalized pilates training plans.
|
- **Type-safe Navigation**: Uses Expo Router with TypeScript for route type safety
|
||||||
- **Workouts**: Daily workout tracking and session management.
|
- **Authentication Flow**: `pushIfAuthedElseLogin` function handles auth-protected navigation
|
||||||
- **AI Features**: AI coach chat and posture assessment capabilities.
|
- **Theme System**: Dynamic theming with light/dark mode support and color tokens
|
||||||
- **Health Integration**: Integration with health data tracking.
|
- **Service Layer**: Centralized API client with interceptors and error handling
|
||||||
- **Content Management**: Article reading and educational content.
|
|
||||||
- **Challenge System**: Challenge participation and progress tracking.
|
|
||||||
- **User Profiles**: Personal information management and goal setting.
|
|
||||||
|
|
||||||
## Directory Structure
|
## Development Conventions
|
||||||
- `app/`: Main application screens and routing
|
- Use absolute imports with `@/` prefix for all internal imports
|
||||||
- `components/`: Reusable UI components
|
- Follow existing Redux slice patterns for state management
|
||||||
- `constants/`: Application constants and configuration
|
- Implement auth guards using `useAuthGuard` hook for protected features
|
||||||
- `hooks/`: Custom React hooks
|
- Use themed components for consistent styling
|
||||||
- `services/`: API service layer
|
- Follow established navigation patterns with typed routes
|
||||||
- `store/`: Redux store and slices
|
|
||||||
- `types/`: TypeScript type definitions
|
|
||||||
|
|
||||||
|
|
||||||
## rules
|
|
||||||
- 路由跳转使用 pushIfAuthedElseLogin
|
|
||||||
@@ -44,9 +44,9 @@ export default function TabLayout() {
|
|||||||
case 'explore':
|
case 'explore':
|
||||||
return { icon: 'magnifyingglass.circle.fill', title: '发现' } as const;
|
return { icon: 'magnifyingglass.circle.fill', title: '发现' } as const;
|
||||||
case 'coach':
|
case 'coach':
|
||||||
return { icon: 'person.3.fill', title: 'Seal' } as const;
|
return { icon: 'message.fill', title: 'AI' } as const;
|
||||||
case 'goals':
|
case 'goals':
|
||||||
return { icon: 'flag.fill', title: '目标' } as const;
|
return { icon: 'flag.fill', title: '习惯' } as const;
|
||||||
case 'statistics':
|
case 'statistics':
|
||||||
return { icon: 'chart.pie.fill', title: '统计' } as const;
|
return { icon: 'chart.pie.fill', title: '统计' } as const;
|
||||||
case 'personal':
|
case 'personal':
|
||||||
|
|||||||
@@ -118,8 +118,17 @@ const CardType = {
|
|||||||
type CardType = typeof CardType[keyof typeof CardType];
|
type CardType = typeof CardType[keyof typeof CardType];
|
||||||
|
|
||||||
|
|
||||||
|
// 定义路由参数类型
|
||||||
|
type CoachScreenParams = {
|
||||||
|
name?: string;
|
||||||
|
action?: 'diet' | 'weight' | 'mood' | 'workout';
|
||||||
|
subAction?: 'record' | 'photo' | 'text' | 'card';
|
||||||
|
meal?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CoachScreen() {
|
export default function CoachScreen() {
|
||||||
const params = useLocalSearchParams<{ name?: string }>();
|
const params = useLocalSearchParams<CoachScreenParams>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
|
||||||
@@ -389,6 +398,69 @@ export default function CoachScreen() {
|
|||||||
};
|
};
|
||||||
}, [insets.bottom]);
|
}, [insets.bottom]);
|
||||||
|
|
||||||
|
// 处理路由参数动作
|
||||||
|
useEffect(() => {
|
||||||
|
// 确保用户已登录且消息已加载
|
||||||
|
if (!isLoggedIn || messages.length === 0) return;
|
||||||
|
|
||||||
|
// 检查是否有动作参数
|
||||||
|
if (params.action) {
|
||||||
|
const executeAction = async () => {
|
||||||
|
try {
|
||||||
|
switch (params.action) {
|
||||||
|
case 'diet':
|
||||||
|
if (params.subAction === 'card') {
|
||||||
|
// 插入饮食记录卡片
|
||||||
|
insertDietInputCard();
|
||||||
|
} else if (params.subAction === 'record' && params.message) {
|
||||||
|
// 直接发送预设的饮食记录消息
|
||||||
|
const mealPrefix = params.meal ? `${getMealDisplayName(params.meal)}` : '';
|
||||||
|
const message = `#记饮食:${mealPrefix}${decodeURIComponent(params.message)}`;
|
||||||
|
await sendStream(message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'weight':
|
||||||
|
if (params.subAction === 'card') {
|
||||||
|
// 插入体重记录卡片
|
||||||
|
insertWeightInputCard();
|
||||||
|
} else if (params.subAction === 'record' && params.message) {
|
||||||
|
// 直接发送预设的体重记录消息
|
||||||
|
const message = `#记体重:${decodeURIComponent(params.message)}`;
|
||||||
|
await sendStream(message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mood':
|
||||||
|
// 跳转到心情记录页面
|
||||||
|
pushIfAuthedElseLogin('/mood/calendar');
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('未知的动作类型:', params.action);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('执行路由动作失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 延迟执行,确保页面已完全加载
|
||||||
|
const timer = setTimeout(executeAction, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [params.action, params.subAction, params.meal, params.message, isLoggedIn, messages.length]);
|
||||||
|
|
||||||
|
// 获取餐次显示名称
|
||||||
|
const getMealDisplayName = (meal: string): string => {
|
||||||
|
const mealNames: Record<string, string> = {
|
||||||
|
breakfast: '早餐',
|
||||||
|
lunch: '午餐',
|
||||||
|
dinner: '晚餐',
|
||||||
|
snack: '加餐'
|
||||||
|
};
|
||||||
|
return mealNames[meal] || '';
|
||||||
|
};
|
||||||
|
|
||||||
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
|
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
|
||||||
|
|
||||||
// 组件卸载时清理流式请求和定时器
|
// 组件卸载时清理流式请求和定时器
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import CreateGoalModal from '@/components/CreateGoalModal';
|
import { CreateGoalModal } from '@/components/CreateGoalModal';
|
||||||
|
import GoalTemplateModal from '@/components/GoalTemplateModal';
|
||||||
import { GoalsPageGuide } from '@/components/GoalsPageGuide';
|
import { GoalsPageGuide } from '@/components/GoalsPageGuide';
|
||||||
import { GuideTestButton } from '@/components/GuideTestButton';
|
import { GuideTestButton } from '@/components/GuideTestButton';
|
||||||
import { TaskCard } from '@/components/TaskCard';
|
import { TaskCard } from '@/components/TaskCard';
|
||||||
@@ -7,6 +8,7 @@ import { TaskProgressCard } from '@/components/TaskProgressCard';
|
|||||||
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
import { useGlobalDialog } from '@/components/ui/DialogProvider';
|
||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||||
|
import { GoalTemplate } from '@/constants/goalTemplates';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
@@ -37,9 +39,7 @@ export default function GoalsScreen() {
|
|||||||
tasksLoading,
|
tasksLoading,
|
||||||
tasksError,
|
tasksError,
|
||||||
tasksPagination,
|
tasksPagination,
|
||||||
completeLoading,
|
|
||||||
completeError,
|
completeError,
|
||||||
skipLoading,
|
|
||||||
skipError,
|
skipError,
|
||||||
} = useAppSelector((state) => state.tasks);
|
} = useAppSelector((state) => state.tasks);
|
||||||
|
|
||||||
@@ -50,11 +50,13 @@ export default function GoalsScreen() {
|
|||||||
|
|
||||||
const userProfile = useAppSelector((state) => state.user.profile);
|
const userProfile = useAppSelector((state) => state.user.profile);
|
||||||
|
|
||||||
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
|
const [selectedFilter, setSelectedFilter] = useState<TaskFilterType>('all');
|
||||||
const [modalKey, setModalKey] = useState(0); // 用于强制重新渲染弹窗
|
const [modalKey, setModalKey] = useState(0); // 用于强制重新渲染弹窗
|
||||||
const [showGuide, setShowGuide] = useState(false); // 控制引导显示
|
const [showGuide, setShowGuide] = useState(false); // 控制引导显示
|
||||||
|
const [selectedTemplateData, setSelectedTemplateData] = useState<Partial<CreateGoalRequest> | undefined>();
|
||||||
|
|
||||||
// 页面聚焦时重新加载数据
|
// 页面聚焦时重新加载数据
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -141,6 +143,29 @@ export default function GoalsScreen() {
|
|||||||
const handleModalSuccess = () => {
|
const handleModalSuccess = () => {
|
||||||
// 不需要在这里改变 modalKey,因为弹窗已经关闭了
|
// 不需要在这里改变 modalKey,因为弹窗已经关闭了
|
||||||
// 下次打开时会自动使用新的 modalKey
|
// 下次打开时会自动使用新的 modalKey
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理模板选择
|
||||||
|
const handleSelectTemplate = (template: GoalTemplate) => {
|
||||||
|
setSelectedTemplateData(template.data);
|
||||||
|
setShowTemplateModal(false);
|
||||||
|
setModalKey(prev => prev + 1);
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理创建自定义目标
|
||||||
|
const handleCreateCustomGoal = () => {
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
setShowTemplateModal(false);
|
||||||
|
setModalKey(prev => prev + 1);
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开模板选择弹窗
|
||||||
|
const handleOpenTemplateModal = () => {
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
setShowTemplateModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 创建目标处理函数
|
// 创建目标处理函数
|
||||||
@@ -353,10 +378,10 @@ export default function GoalsScreen() {
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
|
<Text style={[styles.pageTitle, { color: '#FFFFFF' }]}>
|
||||||
今日目标
|
习惯养成
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
|
<Text style={[styles.pageTitle2, { color: '#FFFFFF' }]}>
|
||||||
让我们检查你的目标!
|
自律让我更健康
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -378,10 +403,7 @@ export default function GoalsScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
style={[styles.cardAddButton, { backgroundColor: colorTokens.primary }]}
|
||||||
onPress={() => {
|
onPress={handleOpenTemplateModal}
|
||||||
setModalKey(prev => prev + 1); // 每次打开弹窗时使用新的 key
|
|
||||||
setShowCreateModal(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.cardAddButtonText}>+</Text>
|
<Text style={styles.cardAddButtonText}>+</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -420,14 +442,26 @@ export default function GoalsScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 目标模板选择弹窗 */}
|
||||||
|
<GoalTemplateModal
|
||||||
|
visible={showTemplateModal}
|
||||||
|
onClose={() => setShowTemplateModal(false)}
|
||||||
|
onSelectTemplate={handleSelectTemplate}
|
||||||
|
onCreateCustom={handleCreateCustomGoal}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 创建目标弹窗 */}
|
{/* 创建目标弹窗 */}
|
||||||
<CreateGoalModal
|
<CreateGoalModal
|
||||||
key={modalKey}
|
key={modalKey}
|
||||||
visible={showCreateModal}
|
visible={showCreateModal}
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setSelectedTemplateData(undefined);
|
||||||
|
}}
|
||||||
onSubmit={handleCreateGoal}
|
onSubmit={handleCreateGoal}
|
||||||
onSuccess={handleModalSuccess}
|
onSuccess={handleModalSuccess}
|
||||||
loading={createLoading}
|
loading={createLoading}
|
||||||
|
initialData={selectedTemplateData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 目标页面引导 */}
|
{/* 目标页面引导 */}
|
||||||
|
|||||||
BIN
assets/images/icons/icon-recommend.png
Normal file
BIN
assets/images/icons/icon-recommend.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -4,7 +4,7 @@ import { CreateGoalRequest, GoalPriority, RepeatType } from '@/types/goals';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import DateTimePicker from '@react-native-community/datetimepicker';
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Image,
|
Image,
|
||||||
@@ -26,6 +26,7 @@ interface CreateGoalModalProps {
|
|||||||
onSubmit: (goalData: CreateGoalRequest) => void;
|
onSubmit: (goalData: CreateGoalRequest) => void;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
initialData?: Partial<CreateGoalRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [
|
const REPEAT_TYPE_OPTIONS: { value: RepeatType; label: string }[] = [
|
||||||
@@ -42,28 +43,49 @@ export const CreateGoalModal: React.FC<CreateGoalModalProps> = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
initialData,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const colorTokens = Colors[theme];
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
// 表单状态
|
// 表单状态
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState(initialData?.title || '');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState(initialData?.description || '');
|
||||||
const [repeatType, setRepeatType] = useState<RepeatType>('daily');
|
const [repeatType, setRepeatType] = useState<RepeatType>(initialData?.repeatType || 'daily');
|
||||||
const [frequency, setFrequency] = useState(1);
|
const [frequency, setFrequency] = useState(initialData?.frequency || 1);
|
||||||
const [hasReminder, setHasReminder] = useState(false);
|
const [hasReminder, setHasReminder] = useState(initialData?.hasReminder || false);
|
||||||
const [showFrequencyPicker, setShowFrequencyPicker] = useState(false);
|
const [showFrequencyPicker, setShowFrequencyPicker] = useState(false);
|
||||||
const [showRepeatTypePicker, setShowRepeatTypePicker] = useState(false);
|
const [showRepeatTypePicker, setShowRepeatTypePicker] = useState(false);
|
||||||
const [reminderTime, setReminderTime] = useState('20:00');
|
const [reminderTime, setReminderTime] = useState(initialData?.reminderTime || '20:00');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState(initialData?.category || '');
|
||||||
const [priority, setPriority] = useState<GoalPriority>(5);
|
const [priority, setPriority] = useState<GoalPriority>(initialData?.priority || 5);
|
||||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||||
const [tempSelectedTime, setTempSelectedTime] = useState<Date | null>(null);
|
const [tempSelectedTime, setTempSelectedTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
// 周几选择状态
|
// 周几选择状态
|
||||||
const [selectedWeekdays, setSelectedWeekdays] = useState<number[]>([1, 2, 3, 4, 5]); // 默认周一到周五
|
const [selectedWeekdays, setSelectedWeekdays] = useState<number[]>(
|
||||||
|
initialData?.customRepeatRule?.weekdays || [1, 2, 3, 4, 5]
|
||||||
|
); // 默认周一到周五
|
||||||
// 每月日期选择状态
|
// 每月日期选择状态
|
||||||
const [selectedMonthDays, setSelectedMonthDays] = useState<number[]>([1, 15]); // 默认1号和15号
|
const [selectedMonthDays, setSelectedMonthDays] = useState<number[]>(
|
||||||
|
initialData?.customRepeatRule?.dayOfMonth || [1, 15]
|
||||||
|
); // 默认1号和15号
|
||||||
|
|
||||||
|
// 当 initialData 变化时更新表单状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setTitle(initialData.title || '');
|
||||||
|
setDescription(initialData.description || '');
|
||||||
|
setRepeatType(initialData.repeatType || 'daily');
|
||||||
|
setFrequency(initialData.frequency || 1);
|
||||||
|
setHasReminder(initialData.hasReminder || false);
|
||||||
|
setReminderTime(initialData.reminderTime || '20:00');
|
||||||
|
setCategory(initialData.category || '');
|
||||||
|
setPriority(initialData.priority || 5);
|
||||||
|
setSelectedWeekdays(initialData.customRepeatRule?.weekdays || [1, 2, 3, 4, 5]);
|
||||||
|
setSelectedMonthDays(initialData.customRepeatRule?.dayOfMonth || [1, 15]);
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
// 重置表单
|
// 重置表单
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
|||||||
295
components/GoalTemplateModal.tsx
Normal file
295
components/GoalTemplateModal.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
import { getTemplatesByCategory, goalCategories, GoalTemplate } from '@/constants/goalTemplates';
|
||||||
|
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dimensions,
|
||||||
|
Image,
|
||||||
|
Modal,
|
||||||
|
SafeAreaView,
|
||||||
|
ScrollView,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface GoalTemplateModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectTemplate: (template: GoalTemplate) => void;
|
||||||
|
onCreateCustom: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GoalTemplateModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onSelectTemplate,
|
||||||
|
onCreateCustom
|
||||||
|
}: GoalTemplateModalProps) {
|
||||||
|
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
|
const colorTokens = Colors[theme];
|
||||||
|
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState('recommended');
|
||||||
|
const currentTemplates = getTemplatesByCategory(selectedCategory);
|
||||||
|
|
||||||
|
// 渲染分类标签
|
||||||
|
const renderCategoryTab = (category: any) => {
|
||||||
|
const isSelected = selectedCategory === category.id;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={category.id}
|
||||||
|
style={[
|
||||||
|
styles.categoryTab,
|
||||||
|
isSelected && styles.categoryTabSelected
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedCategory(category.id)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{category.isRecommended && (
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/icons/icon-recommend.png')}
|
||||||
|
style={{ width: 24, height: 24 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={[
|
||||||
|
styles.categoryTabText,
|
||||||
|
isSelected && styles.categoryTabTextSelected
|
||||||
|
]}>
|
||||||
|
{category.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染模板项
|
||||||
|
const renderTemplateItem = (template: GoalTemplate) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={template.id}
|
||||||
|
style={[styles.templateItem]}
|
||||||
|
onPress={() => onSelectTemplate(template)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={[styles.templateIcon]}>
|
||||||
|
<MaterialIcons
|
||||||
|
name={template.icon as any}
|
||||||
|
size={24}
|
||||||
|
color={template.iconColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.templateTitle]}>
|
||||||
|
{template.title}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor="#FFFFFF" />
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.closeButton}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="keyboard-arrow-down" size={24} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<Text style={styles.title}>创建新目标</Text>
|
||||||
|
|
||||||
|
<View style={{ width: 24 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.content}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* 创建自定义目标 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.customGoalButton}
|
||||||
|
onPress={onCreateCustom}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.customGoalIcon}>
|
||||||
|
<MaterialIcons name="add" size={24} color="#7C3AED" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.customGoalText}>创建自定义目标</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 分类选择器 */}
|
||||||
|
<View style={styles.categorySection}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.categoryScrollContent}
|
||||||
|
style={styles.categoryScrollView}
|
||||||
|
>
|
||||||
|
{goalCategories.map(renderCategoryTab)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 当前分类的模板 */}
|
||||||
|
<View style={styles.templatesSection}>
|
||||||
|
<View style={styles.templatesGrid}>
|
||||||
|
{currentTemplates.map(renderTemplateItem)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F9FAFB',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#E5E7EB',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#111827',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
customGoalButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 20,
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E5E7EB',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
customGoalIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F3E8FF',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
customGoalText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
categorySection: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
categoryScrollView: {
|
||||||
|
paddingLeft: 20,
|
||||||
|
},
|
||||||
|
categoryScrollContent: {
|
||||||
|
paddingRight: 20,
|
||||||
|
},
|
||||||
|
categoryTab: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginRight: 12,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
categoryTabSelected: {
|
||||||
|
backgroundColor: '#7C3AED',
|
||||||
|
},
|
||||||
|
categoryTabText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#374151',
|
||||||
|
},
|
||||||
|
categoryTabTextSelected: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
avatarGroup: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
templatesSection: {
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
templatesGrid: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
templateItem: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
minWidth: (screenWidth - 60) / 2, // 2列布局,考虑间距和padding
|
||||||
|
maxWidth: (screenWidth - 60) / 2,
|
||||||
|
aspectRatio: 2.2,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 3,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
templateIcon: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
templateTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#374151',
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: 20,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ export const TaskProgressCard: React.FC<TaskProgressCardProps> = ({
|
|||||||
{/* 标题区域 */}
|
{/* 标题区域 */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Text style={styles.title}>统计</Text>
|
<Text style={styles.title}>今日</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.goalsIconButton}
|
style={styles.goalsIconButton}
|
||||||
onPress={handleNavigateToGoals}
|
onPress={handleNavigateToGoals}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const MAPPING = {
|
|||||||
'chevron.right': 'chevron-right',
|
'chevron.right': 'chevron-right',
|
||||||
'person.fill': 'person',
|
'person.fill': 'person',
|
||||||
'person.3.fill': 'people',
|
'person.3.fill': 'people',
|
||||||
|
'message.fill': 'message',
|
||||||
} as IconMapping;
|
} as IconMapping;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
227
constants/goalTemplates.ts
Normal file
227
constants/goalTemplates.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { CreateGoalRequest } from '@/types/goals';
|
||||||
|
|
||||||
|
export interface GoalTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
iconColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
category: 'recommended' | 'health' | 'lifestyle' | 'exercise';
|
||||||
|
data: Partial<CreateGoalRequest>;
|
||||||
|
isRecommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GoalCategory {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
isRecommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const goalTemplates: GoalTemplate[] = [
|
||||||
|
// 改善睡眠分类的模板
|
||||||
|
{
|
||||||
|
id: 'afternoon-nap',
|
||||||
|
title: '睡一会午觉',
|
||||||
|
icon: 'hotel',
|
||||||
|
iconColor: '#22D3EE',
|
||||||
|
backgroundColor: '#E0F2FE',
|
||||||
|
category: 'health',
|
||||||
|
data: {
|
||||||
|
title: '午休时间',
|
||||||
|
description: '每天午休30分钟,恢复精力',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '13:00',
|
||||||
|
},
|
||||||
|
isRecommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'regular-bedtime',
|
||||||
|
title: '规律作息',
|
||||||
|
icon: 'access-time',
|
||||||
|
iconColor: '#8B5CF6',
|
||||||
|
backgroundColor: '#F3E8FF',
|
||||||
|
category: 'health',
|
||||||
|
data: {
|
||||||
|
title: '保持规律作息',
|
||||||
|
description: '每天固定时间睡觉起床,建立健康的生物钟',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '22:30',
|
||||||
|
},
|
||||||
|
isRecommended: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 生活习惯分类的模板
|
||||||
|
{
|
||||||
|
id: 'eat-breakfast',
|
||||||
|
title: '坚持吃早餐',
|
||||||
|
icon: 'restaurant',
|
||||||
|
iconColor: '#F97316',
|
||||||
|
backgroundColor: '#FFF7ED',
|
||||||
|
category: 'lifestyle',
|
||||||
|
data: {
|
||||||
|
title: '坚持吃早餐',
|
||||||
|
description: '每天按时吃早餐,保持营养均衡',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '07:30',
|
||||||
|
},
|
||||||
|
isRecommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drink-water',
|
||||||
|
title: '喝八杯水',
|
||||||
|
icon: 'local-drink',
|
||||||
|
iconColor: '#06B6D4',
|
||||||
|
backgroundColor: '#E0F2FE',
|
||||||
|
category: 'lifestyle',
|
||||||
|
data: {
|
||||||
|
title: '每日饮水目标',
|
||||||
|
description: '每天喝足够的水,保持身体水分',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 8,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '09:00',
|
||||||
|
},
|
||||||
|
isRecommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'read-book',
|
||||||
|
title: '看一本新书',
|
||||||
|
icon: 'book',
|
||||||
|
iconColor: '#8B5CF6',
|
||||||
|
backgroundColor: '#F3E8FF',
|
||||||
|
category: 'lifestyle',
|
||||||
|
data: {
|
||||||
|
title: '阅读新书',
|
||||||
|
description: '每天阅读30分钟,丰富知识',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '20:00',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'housework',
|
||||||
|
title: '做一会家务',
|
||||||
|
icon: 'home',
|
||||||
|
iconColor: '#F97316',
|
||||||
|
backgroundColor: '#FFF7ED',
|
||||||
|
category: 'lifestyle',
|
||||||
|
data: {
|
||||||
|
title: '日常家务',
|
||||||
|
description: '每天做一些家务,保持家居整洁',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '19:00',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mindfulness',
|
||||||
|
title: '练习一次正念',
|
||||||
|
icon: 'self-improvement',
|
||||||
|
iconColor: '#10B981',
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
category: 'lifestyle',
|
||||||
|
data: {
|
||||||
|
title: '正念练习',
|
||||||
|
description: '每天练习10分钟正念冥想',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '21:00',
|
||||||
|
},
|
||||||
|
isRecommended: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 运动锻炼分类的模板
|
||||||
|
{
|
||||||
|
id: 'exercise-duration',
|
||||||
|
title: '锻炼时长达标',
|
||||||
|
icon: 'timer',
|
||||||
|
iconColor: '#7C3AED',
|
||||||
|
backgroundColor: '#F3E8FF',
|
||||||
|
category: 'exercise',
|
||||||
|
data: {
|
||||||
|
title: '每日锻炼时长',
|
||||||
|
description: '每天至少锻炼30分钟',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '17:00',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'morning-run',
|
||||||
|
title: '晨跑',
|
||||||
|
icon: 'directions-run',
|
||||||
|
iconColor: '#EF4444',
|
||||||
|
backgroundColor: '#FEF2F2',
|
||||||
|
category: 'exercise',
|
||||||
|
data: {
|
||||||
|
title: '每日晨跑',
|
||||||
|
description: '每天早上跑步30分钟,保持活力',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '06:30',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yoga-practice',
|
||||||
|
title: '瑜伽练习',
|
||||||
|
icon: 'self-improvement',
|
||||||
|
iconColor: '#10B981',
|
||||||
|
backgroundColor: '#ECFDF5',
|
||||||
|
category: 'exercise',
|
||||||
|
data: {
|
||||||
|
title: '每日瑜伽',
|
||||||
|
description: '每天练习瑜伽,提升身体柔韧性',
|
||||||
|
repeatType: 'daily',
|
||||||
|
frequency: 1,
|
||||||
|
hasReminder: true,
|
||||||
|
reminderTime: '19:30',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 分类定义
|
||||||
|
export const goalCategories: GoalCategory[] = [
|
||||||
|
{
|
||||||
|
id: 'recommended',
|
||||||
|
title: '推荐',
|
||||||
|
isRecommended: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
title: '改善睡眠'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lifestyle',
|
||||||
|
title: '生活习惯'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exercise',
|
||||||
|
title: '运动锻炼'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 按类别分组的模板
|
||||||
|
export const getTemplatesByCategory = (category: string) => {
|
||||||
|
if (category === 'recommended') {
|
||||||
|
// 推荐分类显示所有分类中的精选模板
|
||||||
|
return goalTemplates.filter(template => template.isRecommended);
|
||||||
|
}
|
||||||
|
return goalTemplates.filter(template => template.category === category);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取所有分类
|
||||||
|
export const getAllCategories = () => {
|
||||||
|
return goalCategories;
|
||||||
|
};
|
||||||
388
utils/health.ts
388
utils/health.ts
@@ -2,6 +2,12 @@ import dayjs from 'dayjs';
|
|||||||
import type { HealthActivitySummary, HealthKitPermissions } from 'react-native-health';
|
import type { HealthActivitySummary, HealthKitPermissions } from 'react-native-health';
|
||||||
import AppleHealthKit from 'react-native-health';
|
import AppleHealthKit from 'react-native-health';
|
||||||
|
|
||||||
|
type HealthDataOptions = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const PERMISSIONS: HealthKitPermissions = {
|
const PERMISSIONS: HealthKitPermissions = {
|
||||||
permissions: {
|
permissions: {
|
||||||
read: [
|
read: [
|
||||||
@@ -61,122 +67,160 @@ export async function ensureHealthPermissions(): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthData> {
|
// 日期工具函数
|
||||||
console.log('开始获取指定日期健康数据...', date);
|
function createDateRange(date: Date): HealthDataOptions {
|
||||||
|
return {
|
||||||
const start = dayjs(date).startOf('day').toDate();
|
startDate: dayjs(date).startOf('day').toDate().toISOString(),
|
||||||
const end = dayjs(date).endOf('day').toDate();
|
endDate: dayjs(date).endOf('day').toDate().toISOString()
|
||||||
|
|
||||||
const options = {
|
|
||||||
startDate: start.toISOString(),
|
|
||||||
endDate: end.toISOString()
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const activitySummaryOptions = {
|
|
||||||
startDate: start.toISOString(),
|
|
||||||
endDate: end.toISOString()
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
console.log('查询选项:', options);
|
// 睡眠时长计算
|
||||||
|
function calculateSleepDuration(samples: any[]): number {
|
||||||
|
return samples.reduce((total, sample) => {
|
||||||
|
if (sample && sample.startDate && sample.endDate) {
|
||||||
|
const startTime = dayjs(sample.startDate).valueOf();
|
||||||
|
const endTime = dayjs(sample.endDate).valueOf();
|
||||||
|
const durationMinutes = (endTime - startTime) / (1000 * 60);
|
||||||
|
return total + durationMinutes;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// 并行获取所有健康数据,包括ActivitySummary、血氧饱和度和心率
|
// 通用错误处理
|
||||||
const [steps, calories, basalMetabolism, sleepDuration, hrv, activitySummary, oxygenSaturation, heartRate] = await Promise.all([
|
function logError(operation: string, error: any): void {
|
||||||
// 获取步数
|
console.error(`获取${operation}失败:`, error);
|
||||||
new Promise<number>((resolve) => {
|
}
|
||||||
|
|
||||||
|
function logWarning(operation: string, message: string): void {
|
||||||
|
console.warn(`${operation}数据${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSuccess(operation: string, data: any): void {
|
||||||
|
console.log(`${operation}数据:`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数值验证和转换
|
||||||
|
function validateOxygenSaturation(value: any): number | null {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
|
||||||
|
let numValue = Number(value);
|
||||||
|
|
||||||
|
// 如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比
|
||||||
|
if (numValue > 0 && numValue < 1) {
|
||||||
|
numValue = numValue * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 血氧饱和度通常在0-100之间,验证数据有效性
|
||||||
|
if (numValue >= 0 && numValue <= 100) {
|
||||||
|
return Number(numValue.toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('血氧饱和度数据异常:', numValue);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeartRate(value: any): number | null {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
|
||||||
|
const numValue = Number(value);
|
||||||
|
|
||||||
|
// 心率通常在30-200之间,验证数据有效性
|
||||||
|
if (numValue >= 30 && numValue <= 200) {
|
||||||
|
return Math.round(numValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('心率数据异常:', numValue);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康数据获取函数
|
||||||
|
async function fetchStepCount(date: Date): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getStepCount({
|
AppleHealthKit.getStepCount({
|
||||||
date: dayjs(date).toISOString()
|
date: dayjs(date).toDate().toISOString()
|
||||||
}, (err, res) => {
|
}, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取步数失败:', err);
|
logError('步数', err);
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
if (!res) {
|
if (!res) {
|
||||||
console.warn('步数数据为空');
|
logWarning('步数', '为空');
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
console.log('步数数据:', res);
|
logSuccess('步数', res);
|
||||||
resolve(res.value || 0);
|
resolve(res.value || 0);
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取消耗卡路里
|
async function fetchActiveEnergyBurned(options: HealthDataOptions): Promise<number> {
|
||||||
new Promise<number>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
AppleHealthKit.getActiveEnergyBurned(options, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取消耗卡路里失败:', err);
|
logError('消耗卡路里', err);
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
console.warn('卡路里数据为空或格式错误');
|
logWarning('卡路里', '为空或格式错误');
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
console.log('卡路里数据:', res);
|
logSuccess('卡路里', res);
|
||||||
// 求和该日内的所有记录(单位:千卡)
|
|
||||||
const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0);
|
const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0);
|
||||||
resolve(total);
|
resolve(total);
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取基础代谢率
|
async function fetchBasalEnergyBurned(options: HealthDataOptions): Promise<number> {
|
||||||
new Promise<number>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getBasalEnergyBurned(options, (err, res) => {
|
AppleHealthKit.getBasalEnergyBurned(options, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取基础代谢失败:', err);
|
logError('基础代谢', err);
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
console.warn('基础代谢数据为空或格式错误');
|
logWarning('基础代谢', '为空或格式错误');
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
console.log('基础代谢数据:', res);
|
logSuccess('基础代谢', res);
|
||||||
// 求和该日内的所有记录(单位:千卡)
|
|
||||||
const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0);
|
const total = res.reduce((acc: number, item: any) => acc + (item?.value || 0), 0);
|
||||||
resolve(total);
|
resolve(total);
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取睡眠时长
|
async function fetchSleepDuration(options: HealthDataOptions): Promise<number> {
|
||||||
new Promise<number>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getSleepSamples(options, (err, res) => {
|
AppleHealthKit.getSleepSamples(options, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取睡眠数据失败:', err);
|
logError('睡眠数据', err);
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
console.warn('睡眠数据为空或格式错误');
|
logWarning('睡眠', '为空或格式错误');
|
||||||
return resolve(0);
|
return resolve(0);
|
||||||
}
|
}
|
||||||
console.log('睡眠数据:', res);
|
logSuccess('睡眠', res);
|
||||||
|
resolve(calculateSleepDuration(res));
|
||||||
// 计算总睡眠时间(单位:分钟)
|
});
|
||||||
let totalSleepDuration = 0;
|
});
|
||||||
res.forEach((sample: any) => {
|
|
||||||
if (sample && sample.startDate && sample.endDate) {
|
|
||||||
const startTime = new Date(sample.startDate).getTime();
|
|
||||||
const endTime = new Date(sample.endDate).getTime();
|
|
||||||
const durationMinutes = (endTime - startTime) / (1000 * 60);
|
|
||||||
totalSleepDuration += durationMinutes;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
resolve(totalSleepDuration);
|
async function fetchHeartRateVariability(options: HealthDataOptions): Promise<number | null> {
|
||||||
});
|
return new Promise((resolve) => {
|
||||||
}),
|
|
||||||
|
|
||||||
// 获取HRV数据
|
|
||||||
new Promise<number | null>((resolve) => {
|
|
||||||
AppleHealthKit.getHeartRateVariabilitySamples(options, (err, res) => {
|
AppleHealthKit.getHeartRateVariabilitySamples(options, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取HRV数据失败:', err);
|
logError('HRV数据', err);
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
console.warn('HRV数据为空或格式错误');
|
logWarning('HRV', '为空或格式错误');
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
console.log('HRV数据:', res);
|
logSuccess('HRV', res);
|
||||||
|
|
||||||
// 获取最新的HRV值
|
|
||||||
const latestHrv = res[res.length - 1];
|
const latestHrv = res[res.length - 1];
|
||||||
if (latestHrv && latestHrv.value) {
|
if (latestHrv && latestHrv.value) {
|
||||||
resolve(Math.round(latestHrv.value * 1000));
|
resolve(Math.round(latestHrv.value * 1000));
|
||||||
@@ -184,158 +228,158 @@ export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthDat
|
|||||||
resolve(null);
|
resolve(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取ActivitySummary数据(健身圆环数据)
|
async function fetchActivitySummary(options: HealthDataOptions): Promise<HealthActivitySummary | null> {
|
||||||
new Promise<HealthActivitySummary | null>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getActivitySummary(
|
AppleHealthKit.getActivitySummary(
|
||||||
activitySummaryOptions,
|
options,
|
||||||
(err: Object, results: HealthActivitySummary[]) => {
|
(err: Object, results: HealthActivitySummary[]) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取ActivitySummary失败:', err);
|
logError('ActivitySummary', err);
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
if (!results || results.length === 0) {
|
if (!results || results.length === 0) {
|
||||||
console.warn('ActivitySummary数据为空');
|
logWarning('ActivitySummary', '为空');
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
console.log('ActivitySummary数据:', results[0]);
|
logSuccess('ActivitySummary', results[0]);
|
||||||
resolve(results[0]);
|
resolve(results[0]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取血氧饱和度数据
|
async function fetchOxygenSaturation(options: HealthDataOptions): Promise<number | null> {
|
||||||
new Promise<number | null>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getOxygenSaturationSamples(options, (err, res) => {
|
AppleHealthKit.getOxygenSaturationSamples(options, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取血氧饱和度失败:', err);
|
logError('血氧饱和度', err);
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
console.warn('血氧饱和度数据为空或格式错误');
|
logWarning('血氧饱和度', '为空或格式错误');
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
console.log('血氧饱和度数据:', res);
|
logSuccess('血氧饱和度', res);
|
||||||
// 获取最新的血氧饱和度值
|
|
||||||
const latestOxygen = res[res.length - 1];
|
const latestOxygen = res[res.length - 1];
|
||||||
if (latestOxygen && latestOxygen.value !== undefined && latestOxygen.value !== null) {
|
return resolve(validateOxygenSaturation(latestOxygen?.value));
|
||||||
let value = Number(latestOxygen.value);
|
|
||||||
|
|
||||||
// 检查数据格式:如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比
|
|
||||||
if (value > 0 && value < 1) {
|
|
||||||
value = value * 100;
|
|
||||||
console.log('血氧饱和度数据从小数转换为百分比:', latestOxygen.value, '->', value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 血氧饱和度通常在0-100之间,验证数据有效性
|
|
||||||
if (value >= 0 && value <= 100) {
|
|
||||||
resolve(Number(value.toFixed(1)));
|
|
||||||
} else {
|
|
||||||
console.warn('血氧饱和度数据异常:', value);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 获取心率数据
|
async function fetchHeartRate(options: HealthDataOptions): Promise<number | null> {
|
||||||
new Promise<number | null>((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getHeartRateSamples(options, (err, res) => {
|
AppleHealthKit.getHeartRateSamples(options, (err, res) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('获取心率失败:', err);
|
logError('心率', err);
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
if (!res || !Array.isArray(res) || res.length === 0) {
|
||||||
console.warn('心率数据为空或格式错误');
|
logWarning('心率', '为空或格式错误');
|
||||||
return resolve(null);
|
return resolve(null);
|
||||||
}
|
}
|
||||||
console.log('心率数据:', res);
|
logSuccess('心率', res);
|
||||||
// 获取最新的心率值
|
|
||||||
const latestHeartRate = res[res.length - 1];
|
const latestHeartRate = res[res.length - 1];
|
||||||
if (latestHeartRate && latestHeartRate.value !== undefined && latestHeartRate.value !== null) {
|
return resolve(validateHeartRate(latestHeartRate?.value));
|
||||||
// 心率通常在30-200之间,验证数据有效性
|
|
||||||
const value = Number(latestHeartRate.value);
|
|
||||||
if (value >= 30 && value <= 200) {
|
|
||||||
resolve(Math.round(value));
|
|
||||||
} else {
|
|
||||||
console.warn('心率数据异常:', value);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认健康数据
|
||||||
|
function getDefaultHealthData(): TodayHealthData {
|
||||||
|
return {
|
||||||
|
steps: 0,
|
||||||
|
activeEnergyBurned: 0,
|
||||||
|
basalEnergyBurned: 0,
|
||||||
|
sleepDuration: 0,
|
||||||
|
hrv: null,
|
||||||
|
activeCalories: 0,
|
||||||
|
activeCaloriesGoal: 350,
|
||||||
|
exerciseMinutes: 0,
|
||||||
|
exerciseMinutesGoal: 30,
|
||||||
|
standHours: 0,
|
||||||
|
standHoursGoal: 12,
|
||||||
|
oxygenSaturation: null,
|
||||||
|
heartRate: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHealthDataForDate(date: Date): Promise<TodayHealthData> {
|
||||||
|
try {
|
||||||
|
console.log('开始获取指定日期健康数据...', date);
|
||||||
|
|
||||||
|
const options = createDateRange(date);
|
||||||
|
console.log('查询选项:', options);
|
||||||
|
|
||||||
|
// 并行获取所有健康数据
|
||||||
|
const [
|
||||||
|
steps,
|
||||||
|
activeEnergyBurned,
|
||||||
|
basalEnergyBurned,
|
||||||
|
sleepDuration,
|
||||||
|
hrv,
|
||||||
|
activitySummary,
|
||||||
|
oxygenSaturation,
|
||||||
|
heartRate
|
||||||
|
] = await Promise.all([
|
||||||
|
fetchStepCount(date),
|
||||||
|
fetchActiveEnergyBurned(options),
|
||||||
|
fetchBasalEnergyBurned(options),
|
||||||
|
fetchSleepDuration(options),
|
||||||
|
fetchHeartRateVariability(options),
|
||||||
|
fetchActivitySummary(options),
|
||||||
|
fetchOxygenSaturation(options),
|
||||||
|
fetchHeartRate(options)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('指定日期健康数据获取完成:', { steps, calories, basalMetabolism, sleepDuration, hrv, activitySummary, oxygenSaturation, heartRate });
|
console.log('指定日期健康数据获取完成:', {
|
||||||
|
steps,
|
||||||
|
activeEnergyBurned,
|
||||||
|
basalEnergyBurned,
|
||||||
|
sleepDuration,
|
||||||
|
hrv,
|
||||||
|
activitySummary,
|
||||||
|
oxygenSaturation,
|
||||||
|
heartRate
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
steps,
|
steps,
|
||||||
activeEnergyBurned: calories,
|
activeEnergyBurned,
|
||||||
basalEnergyBurned: basalMetabolism,
|
basalEnergyBurned,
|
||||||
sleepDuration,
|
sleepDuration,
|
||||||
hrv,
|
hrv,
|
||||||
// 健身圆环数据
|
|
||||||
activeCalories: Math.round(activitySummary?.activeEnergyBurned || 0),
|
activeCalories: Math.round(activitySummary?.activeEnergyBurned || 0),
|
||||||
activeCaloriesGoal: Math.round(activitySummary?.activeEnergyBurnedGoal || 350),
|
activeCaloriesGoal: Math.round(activitySummary?.activeEnergyBurnedGoal || 350),
|
||||||
exerciseMinutes: Math.round(activitySummary?.appleExerciseTime || 0),
|
exerciseMinutes: Math.round(activitySummary?.appleExerciseTime || 0),
|
||||||
exerciseMinutesGoal: Math.round(activitySummary?.appleExerciseTimeGoal || 30),
|
exerciseMinutesGoal: Math.round(activitySummary?.appleExerciseTimeGoal || 30),
|
||||||
standHours: Math.round(activitySummary?.appleStandHours || 0),
|
standHours: Math.round(activitySummary?.appleStandHours || 0),
|
||||||
standHoursGoal: Math.round(activitySummary?.appleStandHoursGoal || 12),
|
standHoursGoal: Math.round(activitySummary?.appleStandHoursGoal || 12),
|
||||||
// 血氧饱和度和心率数据
|
|
||||||
oxygenSaturation,
|
oxygenSaturation,
|
||||||
heartRate
|
heartRate
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取指定日期健康数据失败:', error);
|
||||||
|
return getDefaultHealthData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTodayHealthData(): Promise<TodayHealthData> {
|
export async function fetchTodayHealthData(): Promise<TodayHealthData> {
|
||||||
return fetchHealthDataForDate(new Date());
|
return fetchHealthDataForDate(dayjs().toDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增:专门获取HRV数据的函数
|
|
||||||
export async function fetchHRVForDate(date: Date): Promise<number | null> {
|
export async function fetchHRVForDate(date: Date): Promise<number | null> {
|
||||||
console.log('开始获取指定日期HRV数据...', date);
|
console.log('开始获取指定日期HRV数据...', date);
|
||||||
|
const options = createDateRange(date);
|
||||||
const start = new Date(date);
|
return fetchHeartRateVariability(options);
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
const end = new Date(date);
|
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
startDate: start.toISOString(),
|
|
||||||
endDate: end.toISOString()
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
return new Promise<number | null>((resolve) => {
|
|
||||||
AppleHealthKit.getHeartRateVariabilitySamples(options, (err, res) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('获取HRV数据失败:', err);
|
|
||||||
return resolve(null);
|
|
||||||
}
|
|
||||||
if (!res || !Array.isArray(res) || res.length === 0) {
|
|
||||||
console.warn('HRV数据为空或格式错误');
|
|
||||||
return resolve(null);
|
|
||||||
}
|
|
||||||
console.log('HRV数据:', res);
|
|
||||||
|
|
||||||
// 获取最新的HRV值
|
|
||||||
const latestHrv = res[res.length - 1];
|
|
||||||
if (latestHrv && latestHrv.value) {
|
|
||||||
resolve(latestHrv.value);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增:获取今日HRV数据
|
|
||||||
export async function fetchTodayHRV(): Promise<number | null> {
|
export async function fetchTodayHRV(): Promise<number | null> {
|
||||||
return fetchHRVForDate(new Date());
|
return fetchHRVForDate(dayjs().toDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新healthkit中的体重
|
// 更新healthkit中的体重
|
||||||
@@ -354,17 +398,10 @@ export async function updateWeight(weight: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增:测试血氧饱和度数据获取
|
export async function testOxygenSaturationData(date: Date = dayjs().toDate()): Promise<void> {
|
||||||
export async function testOxygenSaturationData(date: Date = new Date()): Promise<void> {
|
|
||||||
console.log('=== 开始测试血氧饱和度数据获取 ===');
|
console.log('=== 开始测试血氧饱和度数据获取 ===');
|
||||||
|
|
||||||
const start = dayjs(date).startOf('day').toDate();
|
const options = createDateRange(date);
|
||||||
const end = dayjs(date).endOf('day').toDate();
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
startDate: start.toISOString(),
|
|
||||||
endDate: end.toISOString()
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
AppleHealthKit.getOxygenSaturationSamples(options, (err, res) => {
|
AppleHealthKit.getOxygenSaturationSamples(options, (err, res) => {
|
||||||
@@ -392,23 +429,14 @@ export async function testOxygenSaturationData(date: Date = new Date()): Promise
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 获取最新的血氧饱和度值
|
// 获取最新的血氧饱和度值并验证
|
||||||
const latestOxygen = res[res.length - 1];
|
const latestOxygen = res[res.length - 1];
|
||||||
if (latestOxygen && latestOxygen.value !== undefined && latestOxygen.value !== null) {
|
if (latestOxygen?.value !== undefined && latestOxygen?.value !== null) {
|
||||||
let value = Number(latestOxygen.value);
|
const processedValue = validateOxygenSaturation(latestOxygen.value);
|
||||||
|
|
||||||
console.log('处理前的值:', latestOxygen.value);
|
console.log('处理前的值:', latestOxygen.value);
|
||||||
console.log('转换为数字后的值:', value);
|
console.log('最终处理后的值:', processedValue);
|
||||||
|
console.log('数据有效性检查:', processedValue !== null ? '有效' : '无效');
|
||||||
// 检查数据格式:如果值小于1,可能是小数形式(0.0-1.0),需要转换为百分比
|
|
||||||
if (value > 0 && value < 1) {
|
|
||||||
const originalValue = value;
|
|
||||||
value = value * 100;
|
|
||||||
console.log('血氧饱和度数据从小数转换为百分比:', originalValue, '->', value);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('最终处理后的值:', value);
|
|
||||||
console.log('数据有效性检查:', value >= 0 && value <= 100 ? '有效' : '无效');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('=== 血氧饱和度数据测试完成 ===');
|
console.log('=== 血氧饱和度数据测试完成 ===');
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { NotificationData, notificationService } from '../services/notifications';
|
import { NotificationData, notificationService } from '../services/notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 coach 页面的深度链接
|
||||||
|
*/
|
||||||
|
export function buildCoachDeepLink(params: {
|
||||||
|
action?: 'diet' | 'weight' | 'mood' | 'workout';
|
||||||
|
subAction?: 'record' | 'photo' | 'text' | 'card';
|
||||||
|
meal?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||||
|
message?: string;
|
||||||
|
}): string {
|
||||||
|
const baseUrl = '/coach';
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.action) searchParams.set('action', params.action);
|
||||||
|
if (params.subAction) searchParams.set('subAction', params.subAction);
|
||||||
|
if (params.meal) searchParams.set('meal', params.meal);
|
||||||
|
if (params.message) searchParams.set('message', encodeURIComponent(params.message));
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 运动相关的通知辅助函数
|
* 运动相关的通知辅助函数
|
||||||
*/
|
*/
|
||||||
@@ -353,6 +374,13 @@ export class NutritionNotificationHelpers {
|
|||||||
return existingLunchReminder.identifier;
|
return existingLunchReminder.identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建跳转到 coach 页面的深度链接
|
||||||
|
const coachUrl = buildCoachDeepLink({
|
||||||
|
action: 'diet',
|
||||||
|
subAction: 'card',
|
||||||
|
meal: 'lunch'
|
||||||
|
});
|
||||||
|
|
||||||
// 创建午餐提醒通知
|
// 创建午餐提醒通知
|
||||||
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
|
const notificationId = await notificationService.scheduleCalendarRepeatingNotification(
|
||||||
{
|
{
|
||||||
@@ -361,7 +389,8 @@ export class NutritionNotificationHelpers {
|
|||||||
data: {
|
data: {
|
||||||
type: 'lunch_reminder',
|
type: 'lunch_reminder',
|
||||||
isDailyReminder: true,
|
isDailyReminder: true,
|
||||||
meal: '午餐'
|
meal: '午餐',
|
||||||
|
url: coachUrl // 添加深度链接
|
||||||
},
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
@@ -385,10 +414,20 @@ export class NutritionNotificationHelpers {
|
|||||||
* 发送午餐记录提醒
|
* 发送午餐记录提醒
|
||||||
*/
|
*/
|
||||||
static async sendLunchReminder(userName: string) {
|
static async sendLunchReminder(userName: string) {
|
||||||
|
const coachUrl = buildCoachDeepLink({
|
||||||
|
action: 'diet',
|
||||||
|
subAction: 'card',
|
||||||
|
meal: 'lunch'
|
||||||
|
});
|
||||||
|
|
||||||
return notificationService.sendImmediateNotification({
|
return notificationService.sendImmediateNotification({
|
||||||
title: '午餐记录提醒',
|
title: '午餐记录提醒',
|
||||||
body: `${userName},记得记录今天的午餐情况哦!`,
|
body: `${userName},记得记录今天的午餐情况哦!`,
|
||||||
data: { type: 'lunch_reminder', meal: '午餐' },
|
data: {
|
||||||
|
type: 'lunch_reminder',
|
||||||
|
meal: '午餐',
|
||||||
|
url: coachUrl
|
||||||
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
});
|
});
|
||||||
@@ -436,11 +475,28 @@ export class NutritionNotificationHelpers {
|
|||||||
reminderTime.setDate(reminderTime.getDate() + 1);
|
reminderTime.setDate(reminderTime.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建深度链接
|
||||||
|
const mealTypeMap: Record<string, string> = {
|
||||||
|
'早餐': 'breakfast',
|
||||||
|
'午餐': 'lunch',
|
||||||
|
'晚餐': 'dinner'
|
||||||
|
};
|
||||||
|
|
||||||
|
const coachUrl = buildCoachDeepLink({
|
||||||
|
action: 'diet',
|
||||||
|
subAction: 'card',
|
||||||
|
meal: mealTypeMap[mealTime.meal] as 'breakfast' | 'lunch' | 'dinner'
|
||||||
|
});
|
||||||
|
|
||||||
const notificationId = await notificationService.scheduleRepeatingNotification(
|
const notificationId = await notificationService.scheduleRepeatingNotification(
|
||||||
{
|
{
|
||||||
title: `${mealTime.meal}提醒`,
|
title: `${mealTime.meal}提醒`,
|
||||||
body: `${userName},记得记录您的${mealTime.meal}情况`,
|
body: `${userName},记得记录您的${mealTime.meal}情况`,
|
||||||
data: { type: 'meal_reminder', meal: mealTime.meal },
|
data: {
|
||||||
|
type: 'meal_reminder',
|
||||||
|
meal: mealTime.meal,
|
||||||
|
url: coachUrl
|
||||||
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
},
|
},
|
||||||
@@ -575,19 +631,50 @@ export const NotificationTemplates = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
nutrition: {
|
nutrition: {
|
||||||
reminder: (userName: string, meal: string) => ({
|
reminder: (userName: string, meal: string) => {
|
||||||
|
const mealTypeMap: Record<string, string> = {
|
||||||
|
'早餐': 'breakfast',
|
||||||
|
'午餐': 'lunch',
|
||||||
|
'晚餐': 'dinner',
|
||||||
|
'加餐': 'snack'
|
||||||
|
};
|
||||||
|
|
||||||
|
const coachUrl = buildCoachDeepLink({
|
||||||
|
action: 'diet',
|
||||||
|
subAction: 'card',
|
||||||
|
meal: mealTypeMap[meal] as 'breakfast' | 'lunch' | 'dinner' | 'snack'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
title: `${meal}提醒`,
|
title: `${meal}提醒`,
|
||||||
body: `${userName},记得记录您的${meal}情况`,
|
body: `${userName},记得记录您的${meal}情况`,
|
||||||
data: { type: 'meal_reminder', meal },
|
data: {
|
||||||
|
type: 'meal_reminder',
|
||||||
|
meal,
|
||||||
|
url: coachUrl
|
||||||
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'normal' as const,
|
priority: 'normal' as const,
|
||||||
}),
|
};
|
||||||
lunch: (userName: string) => ({
|
},
|
||||||
|
lunch: (userName: string) => {
|
||||||
|
const coachUrl = buildCoachDeepLink({
|
||||||
|
action: 'diet',
|
||||||
|
subAction: 'card',
|
||||||
|
meal: 'lunch'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
title: '午餐记录提醒',
|
title: '午餐记录提醒',
|
||||||
body: `${userName},记得记录今天的午餐情况哦!`,
|
body: `${userName},记得记录今天的午餐情况哦!`,
|
||||||
data: { type: 'lunch_reminder', meal: '午餐' },
|
data: {
|
||||||
|
type: 'lunch_reminder',
|
||||||
|
meal: '午餐',
|
||||||
|
url: coachUrl
|
||||||
|
},
|
||||||
sound: true,
|
sound: true,
|
||||||
priority: 'normal' as const,
|
priority: 'normal' as const,
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user