feat(workout): 新增锻炼结束监听和个性化通知功能
实现了iOS HealthKit锻炼数据实时监听,当用户完成锻炼时自动发送个性化鼓励通知。包括锻炼类型筛选、时间范围控制、用户偏好设置等完整功能,并提供了测试工具和详细文档。
This commit is contained in:
@@ -5,3 +5,5 @@
|
|||||||
|
|
||||||
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
- 遇到比较复杂的页面,尽量使用可以复用的组件
|
||||||
- 不要尝试使用 `npm run ios` 命令
|
- 不要尝试使用 `npm run ios` 命令
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { log } from '@/utils/logger';
|
|||||||
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
import { getNotificationEnabled, setNotificationEnabled as saveNotificationEnabled } from '@/utils/userPreferences';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { isLiquidGlassAvailable } from 'expo-glass-effect';
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -216,9 +216,17 @@ export default function PersonalScreen() {
|
|||||||
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
<Text style={styles.userMemberNumber}>会员编号: {userProfile.memberNumber}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
{isLgAvaliable ? (
|
||||||
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
</TouchableOpacity>
|
<GlassView style={styles.editButtonGlass}>
|
||||||
|
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
|
</GlassView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
|
||||||
|
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -494,8 +502,16 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
},
|
},
|
||||||
|
editButtonGlass: {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
editButtonText: {
|
editButtonText: {
|
||||||
color: '#FFFFFF',
|
color: 'rgba(147, 112, 219, 1)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { notificationService } from '@/services/notifications';
|
|||||||
import { setupQuickActions } from '@/services/quickActions';
|
import { setupQuickActions } from '@/services/quickActions';
|
||||||
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
import { initializeWaterRecordBridge } from '@/services/waterRecordBridge';
|
||||||
import { WaterRecordSource } from '@/services/waterRecords';
|
import { WaterRecordSource } from '@/services/waterRecords';
|
||||||
|
import { workoutMonitorService } from '@/services/workoutMonitor';
|
||||||
import { store } from '@/store';
|
import { store } from '@/store';
|
||||||
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice';
|
||||||
import { createWaterRecordAction } from '@/store/waterSlice';
|
import { createWaterRecordAction } from '@/store/waterSlice';
|
||||||
@@ -102,6 +103,18 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
|
|||||||
initializeWaterRecordBridge();
|
initializeWaterRecordBridge();
|
||||||
console.log('喝水记录 Bridge 初始化成功');
|
console.log('喝水记录 Bridge 初始化成功');
|
||||||
|
|
||||||
|
// 初始化锻炼监听服务
|
||||||
|
const initializeWorkoutMonitoring = async () => {
|
||||||
|
try {
|
||||||
|
await workoutMonitorService.initialize();
|
||||||
|
console.log('锻炼监听服务初始化成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('锻炼监听服务初始化失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeWorkoutMonitoring();
|
||||||
|
|
||||||
// 检查并同步Widget数据更改
|
// 检查并同步Widget数据更改
|
||||||
const widgetSync = await syncPendingWidgetChanges();
|
const widgetSync = await syncPendingWidgetChanges();
|
||||||
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
if (widgetSync.hasPendingChanges && widgetSync.pendingRecords) {
|
||||||
@@ -204,6 +217,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
<Stack.Screen name="article/[id]" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
<Stack.Screen name="water-detail" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
<Stack.Screen name="water-settings" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="workout/notification-settings" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
|||||||
339
app/workout/notification-settings.tsx
Normal file
339
app/workout/notification-settings.tsx
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import {
|
||||||
|
getWorkoutNotificationPreferences,
|
||||||
|
resetWorkoutNotificationPreferences,
|
||||||
|
saveWorkoutNotificationPreferences,
|
||||||
|
WorkoutNotificationPreferences
|
||||||
|
} from '@/utils/workoutPreferences';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
const WORKOUT_TYPES = [
|
||||||
|
{ key: 'running', label: '跑步' },
|
||||||
|
{ key: 'cycling', label: '骑行' },
|
||||||
|
{ key: 'swimming', label: '游泳' },
|
||||||
|
{ key: 'yoga', label: '瑜伽' },
|
||||||
|
{ key: 'functionalstrengthtraining', label: '功能性力量训练' },
|
||||||
|
{ key: 'traditionalstrengthtraining', label: '传统力量训练' },
|
||||||
|
{ key: 'highintensityintervaltraining', label: '高强度间歇训练' },
|
||||||
|
{ key: 'walking', label: '步行' },
|
||||||
|
{ key: 'other', label: '其他运动' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WorkoutNotificationSettingsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [preferences, setPreferences] = useState<WorkoutNotificationPreferences>({
|
||||||
|
enabled: true,
|
||||||
|
startTimeHour: 8,
|
||||||
|
endTimeHour: 22,
|
||||||
|
enabledWorkoutTypes: [],
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPreferences();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
try {
|
||||||
|
const prefs = await getWorkoutNotificationPreferences();
|
||||||
|
setPreferences(prefs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载偏好设置失败:', error);
|
||||||
|
Alert.alert('错误', '加载设置失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePreferences = async (newPreferences: Partial<WorkoutNotificationPreferences>) => {
|
||||||
|
try {
|
||||||
|
await saveWorkoutNotificationPreferences(newPreferences);
|
||||||
|
setPreferences(prev => ({ ...prev, ...newPreferences }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存偏好设置失败:', error);
|
||||||
|
Alert.alert('错误', '保存设置失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnabledToggle = (enabled: boolean) => {
|
||||||
|
savePreferences({ enabled });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeRangeChange = (type: 'start' | 'end', hour: number) => {
|
||||||
|
if (type === 'start') {
|
||||||
|
savePreferences({ startTimeHour: hour });
|
||||||
|
} else {
|
||||||
|
savePreferences({ endTimeHour: hour });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWorkoutTypeToggle = (workoutType: string) => {
|
||||||
|
const currentTypes = preferences.enabledWorkoutTypes;
|
||||||
|
let newTypes: string[];
|
||||||
|
|
||||||
|
if (currentTypes.includes(workoutType)) {
|
||||||
|
newTypes = currentTypes.filter(type => type !== workoutType);
|
||||||
|
} else {
|
||||||
|
newTypes = [...currentTypes, workoutType];
|
||||||
|
}
|
||||||
|
|
||||||
|
savePreferences({ enabledWorkoutTypes: newTypes });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'重置设置',
|
||||||
|
'确定要重置所有锻炼通知设置为默认值吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '重置',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await resetWorkoutNotificationPreferences();
|
||||||
|
await loadPreferences();
|
||||||
|
Alert.alert('成功', '设置已重置为默认值');
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('错误', '重置设置失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatHour = (hour: number) => {
|
||||||
|
return `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeSelector = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onValueChange
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onValueChange: (hour: number) => void;
|
||||||
|
}) => (
|
||||||
|
<View style={styles.timeSelector}>
|
||||||
|
<Text style={styles.timeLabel}>{label}</Text>
|
||||||
|
<View style={styles.timeButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.timeButton, value === 0 && styles.timeButtonDisabled]}
|
||||||
|
onPress={() => onValueChange(Math.max(0, value - 1))}
|
||||||
|
disabled={value === 0}
|
||||||
|
>
|
||||||
|
<Text style={styles.timeButtonText}>-</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.timeValue}>{formatHour(value)}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.timeButton, value === 23 && styles.timeButtonDisabled]}
|
||||||
|
onPress={() => onValueChange(Math.min(23, value + 1))}
|
||||||
|
disabled={value === 23}
|
||||||
|
>
|
||||||
|
<Text style={styles.timeButtonText}>+</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<HeaderBar title="锻炼通知设置" onBack={() => router.back()} />
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* 主开关 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<Text style={styles.settingLabel}>锻炼完成通知</Text>
|
||||||
|
<Switch
|
||||||
|
value={preferences.enabled}
|
||||||
|
onValueChange={handleEnabledToggle}
|
||||||
|
trackColor={{ false: '#E5E5E5', true: '#4CAF50' }}
|
||||||
|
thumbColor={preferences.enabled ? '#FFFFFF' : '#FFFFFF'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.settingDescription}>
|
||||||
|
当您完成锻炼时,发送个性化的鼓励通知
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{preferences.enabled && (
|
||||||
|
<>
|
||||||
|
{/* 时间范围设置 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>通知时间范围</Text>
|
||||||
|
<Text style={styles.sectionDescription}>
|
||||||
|
只在指定时间段内发送通知,避免深夜打扰
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TimeSelector
|
||||||
|
label="开始时间"
|
||||||
|
value={preferences.startTimeHour}
|
||||||
|
onValueChange={(hour) => handleTimeRangeChange('start', hour)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TimeSelector
|
||||||
|
label="结束时间"
|
||||||
|
value={preferences.endTimeHour}
|
||||||
|
onValueChange={(hour) => handleTimeRangeChange('end', hour)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 锻炼类型设置 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>锻炼类型</Text>
|
||||||
|
<Text style={styles.sectionDescription}>
|
||||||
|
选择要接收通知的锻炼类型,不选择表示接收所有类型
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{WORKOUT_TYPES.map((type) => (
|
||||||
|
<View key={type.key} style={styles.settingItem}>
|
||||||
|
<Text style={styles.settingLabel}>{type.label}</Text>
|
||||||
|
<Switch
|
||||||
|
value={
|
||||||
|
preferences.enabledWorkoutTypes.length === 0 ||
|
||||||
|
preferences.enabledWorkoutTypes.includes(type.key)
|
||||||
|
}
|
||||||
|
onValueChange={() => handleWorkoutTypeToggle(type.key)}
|
||||||
|
trackColor={{ false: '#E5E5E5', true: '#4CAF50' }}
|
||||||
|
thumbColor="#FFFFFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 重置按钮 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<TouchableOpacity style={styles.resetButton} onPress={handleReset}>
|
||||||
|
<Text style={styles.resetButtonText}>重置为默认设置</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
sectionDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
marginBottom: 16,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
settingLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#1A1A1A',
|
||||||
|
},
|
||||||
|
settingDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
marginTop: -8,
|
||||||
|
marginBottom: 8,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
timeSelector: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#1A1A1A',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
timeButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
timeButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: '#4CAF50',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
},
|
||||||
|
timeButtonDisabled: {
|
||||||
|
backgroundColor: '#E5E5E5',
|
||||||
|
},
|
||||||
|
timeButtonText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
timeValue: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1A1A1A',
|
||||||
|
minWidth: 60,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
resetButton: {
|
||||||
|
backgroundColor: '#F44336',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
resetButtonText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
components/statistic/.HealthDataCard.tsx.swp
Normal file
BIN
components/statistic/.HealthDataCard.tsx.swp
Normal file
Binary file not shown.
@@ -6,6 +6,7 @@ import { useColorScheme } from '@/hooks/useColorScheme';
|
|||||||
import { fetchWeightHistory } from '@/store/userSlice';
|
import { fetchWeightHistory } from '@/store/userSlice';
|
||||||
import { BMI_CATEGORIES } from '@/utils/bmi';
|
import { BMI_CATEGORIES } from '@/utils/bmi';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@@ -33,7 +34,7 @@ export function WeightHistoryCard() {
|
|||||||
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
const weightHistory = useAppSelector((s) => s.user.weightHistory);
|
||||||
|
|
||||||
const [showBMIModal, setShowBMIModal] = useState(false);
|
const [showBMIModal, setShowBMIModal] = useState(false);
|
||||||
|
const isLgAvaliable = isLiquidGlassAvailable();
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
@@ -133,16 +134,30 @@ export function WeightHistoryCard() {
|
|||||||
style={styles.iconSquare}
|
style={styles.iconSquare}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.cardTitle}>体重记录</Text>
|
<Text style={styles.cardTitle}>体重记录</Text>
|
||||||
<TouchableOpacity
|
{isLgAvaliable ? (
|
||||||
style={styles.addButton}
|
<TouchableOpacity
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigateToCoach();
|
navigateToCoach();
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
<GlassView style={styles.addButtonGlass}>
|
||||||
</TouchableOpacity>
|
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
||||||
|
</GlassView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addButton}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigateToCoach();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Ionicons name="add" size={18} color={Colors.light.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 默认显示图表 */}
|
{/* 默认显示图表 */}
|
||||||
@@ -352,6 +367,14 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
addButtonGlass: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(147, 112, 219, 0.3)',
|
||||||
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
171
docs/workout-monitoring-feature.md
Normal file
171
docs/workout-monitoring-feature.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# 锻炼结束监听和推送通知功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
这个功能实现了在 iOS 设备上监听用户锻炼结束事件,并在锻炼完成后自动发送个性化的鼓励推送通知。当用户完成锻炼(如跑步30分钟)时,系统会自动获取锻炼数据并分析,然后发送包含锻炼详情的鼓励消息。
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### iOS 原生层
|
||||||
|
- **HKObserverQuery**: 使用 HealthKit 的观察者查询监听锻炼数据变化
|
||||||
|
- **后台执行**: 配置应用在后台执行查询的能力
|
||||||
|
- **事件桥接**: 通过 React Native 桥接将原生事件传递到 JS 层
|
||||||
|
|
||||||
|
### JavaScript 服务层
|
||||||
|
- **WorkoutMonitorService**: 锻炼监听服务,处理原生事件
|
||||||
|
- **WorkoutNotificationService**: 锻炼通知服务,分析和发送通知
|
||||||
|
- **WorkoutPreferences**: 用户偏好设置管理
|
||||||
|
|
||||||
|
### 通知系统
|
||||||
|
- **个性化内容**: 基于锻炼类型、持续时间、消耗卡路里等生成定制消息
|
||||||
|
- **智能时机**: 避免深夜打扰,尊重用户偏好设置
|
||||||
|
- **用户控制**: 完整的开关和类型筛选功能
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/OutLive/
|
||||||
|
├── HealthKitManager.swift # iOS 原生 HealthKit 管理器(已扩展)
|
||||||
|
├── HealthKitManager.m # Objective-C 桥接文件(已扩展)
|
||||||
|
|
||||||
|
services/
|
||||||
|
├── workoutMonitor.ts # 锻炼监听服务
|
||||||
|
├── workoutNotificationService.ts # 锻炼通知服务
|
||||||
|
└── notifications.ts # 通知服务(已扩展)
|
||||||
|
|
||||||
|
utils/
|
||||||
|
├── workoutPreferences.ts # 锻炼通知偏好设置
|
||||||
|
└── workoutTestHelper.ts # 测试工具
|
||||||
|
|
||||||
|
app/workout/
|
||||||
|
└── notification-settings.tsx # 通知设置页面
|
||||||
|
|
||||||
|
app/_layout.tsx # 应用入口(已集成)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 实时监听
|
||||||
|
- 使用 `HKObserverQuery` 实现真正的实时监听
|
||||||
|
- 当 HealthKit 中有新的锻炼数据时立即触发
|
||||||
|
- 支持后台执行,即使应用在后台也能监听
|
||||||
|
|
||||||
|
### 2. 智能分析
|
||||||
|
- 复用现有的锻炼数据分析功能
|
||||||
|
- 支持心率区间分析、METs 计算、强度评估
|
||||||
|
- 基于锻炼类型生成个性化鼓励内容
|
||||||
|
|
||||||
|
### 3. 个性化通知
|
||||||
|
不同锻炼类型的专属消息:
|
||||||
|
- **跑步**: "🏃♂️ 跑步完成!太棒了!您刚刚完成了30分钟的跑步,消耗了约250千卡热量。平均心率140次/分。坚持运动让身体更健康!💪"
|
||||||
|
- **瑜伽**: "🧘♀️ 瑜伽完成!45分钟的瑜伽练习完成!提升了柔韧性和内心平静。继续保持这份宁静!🌸"
|
||||||
|
- **HIIT**: "🔥 HIIT训练完成!高强度间歇训练20分钟完成!消耗了约200千卡热量。心肺功能得到有效提升,您的努力值得称赞!⚡"
|
||||||
|
|
||||||
|
### 4. 用户控制
|
||||||
|
- **功能开关**: 允许用户启用/禁用锻炼结束通知
|
||||||
|
- **时间段设置**: 限制通知发送时间(如 8:00-22:00)
|
||||||
|
- **锻炼类型筛选**: 允许用户选择关心的锻炼类型
|
||||||
|
- **通知频率控制**: 避免短时间内重复通知
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 用户设置
|
||||||
|
1. 打开应用
|
||||||
|
2. 进入设置页面(可通过锻炼历史页面访问)
|
||||||
|
3. 配置锻炼通知偏好:
|
||||||
|
- 启用/禁用功能
|
||||||
|
- 设置通知时间范围
|
||||||
|
- 选择要接收通知的锻炼类型
|
||||||
|
|
||||||
|
### 开发者测试
|
||||||
|
```typescript
|
||||||
|
import { WorkoutTestHelper } from '@/utils/workoutTestHelper';
|
||||||
|
|
||||||
|
// 测试通知功能
|
||||||
|
await WorkoutTestHelper.testWorkoutNotifications();
|
||||||
|
|
||||||
|
// 测试偏好设置
|
||||||
|
await WorkoutTestHelper.testPreferences();
|
||||||
|
|
||||||
|
// 模拟锻炼完成
|
||||||
|
await WorkoutTestHelper.simulateWorkoutCompletion();
|
||||||
|
|
||||||
|
// 运行完整测试套件
|
||||||
|
await WorkoutTestHelper.runFullTestSuite();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 权限要求
|
||||||
|
|
||||||
|
### iOS 权限
|
||||||
|
1. **HealthKit 权限**: 需要读取锻炼数据
|
||||||
|
2. **通知权限**: 需要发送推送通知
|
||||||
|
3. **后台执行权限**: 需要在后台监听数据变化
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
在 `app.json` 中已配置必要的后台模式:
|
||||||
|
```json
|
||||||
|
"UIBackgroundModes": [
|
||||||
|
"processing",
|
||||||
|
"fetch",
|
||||||
|
"remote-notification"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 防抖处理
|
||||||
|
- 使用 5 秒延迟避免短时间内重复处理
|
||||||
|
- 确保 HealthKit 数据完全更新后再处理
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
- 完善的错误捕获和日志记录
|
||||||
|
- 权限检查和用户友好提示
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
- 使用 HealthKit 原生后台查询,最小化电池消耗
|
||||||
|
- 智能判断锻炼结束时机,避免频繁查询
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
1. **通知未发送**
|
||||||
|
- 检查通知权限是否已授予
|
||||||
|
- 检查锻炼通知是否在设置中启用
|
||||||
|
- 检查当前时间是否在允许的时间范围内
|
||||||
|
|
||||||
|
2. **监听不工作**
|
||||||
|
- 确保 HealthKit 权限已授予
|
||||||
|
- 检查应用是否有后台执行权限
|
||||||
|
- 重启应用重新初始化监听服务
|
||||||
|
|
||||||
|
3. **重复通知**
|
||||||
|
- 系统有防重复机制,记录已处理的锻炼ID
|
||||||
|
- 如果出现问题,可以清除应用数据重新开始
|
||||||
|
|
||||||
|
### 调试方法
|
||||||
|
1. 使用 `WorkoutTestHelper` 进行测试
|
||||||
|
2. 查看控制台日志了解详细执行过程
|
||||||
|
3. 检查 iOS 设备的设置中的通知权限
|
||||||
|
|
||||||
|
## 未来扩展
|
||||||
|
|
||||||
|
1. **更多锻炼类型支持**: 添加更多运动类型的个性化消息
|
||||||
|
2. **成就系统**: 基于锻炼历史生成成就和里程碑
|
||||||
|
3. **社交分享**: 允许用户分享锻炼成就
|
||||||
|
4. **智能建议**: 基于锻炼数据提供个性化建议
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **电池使用**: 虽然使用了优化的后台查询,但仍会消耗一定电量
|
||||||
|
2. **隐私保护**: 所有数据仅在本地处理,不会上传到服务器
|
||||||
|
3. **兼容性**: 仅支持 iOS,需要 iOS 16.0+
|
||||||
|
4. **测试环境**: 在模拟器上可能无法完全测试,建议使用真机
|
||||||
|
|
||||||
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.0.0
|
||||||
|
- 初始版本发布
|
||||||
|
- 支持基本的锻炼结束监听和通知
|
||||||
|
- 完整的用户偏好设置
|
||||||
|
- 测试工具和文档
|
||||||
@@ -82,4 +82,11 @@ RCT_EXTERN_METHOD(getRecentWorkouts:(NSDictionary *)options
|
|||||||
resolver:(RCTPromiseResolveBlock)resolver
|
resolver:(RCTPromiseResolveBlock)resolver
|
||||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
// Workout Observer Methods
|
||||||
|
RCT_EXTERN_METHOD(startWorkoutObserver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
|
RCT_EXTERN_METHOD(stopWorkoutObserver:(RCTPromiseResolveBlock)resolver
|
||||||
|
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||||
|
|
||||||
@end
|
@end
|
||||||
@@ -10,17 +10,14 @@ import React
|
|||||||
import HealthKit
|
import HealthKit
|
||||||
|
|
||||||
@objc(HealthKitManager)
|
@objc(HealthKitManager)
|
||||||
class HealthKitManager: NSObject, RCTBridgeModule {
|
class HealthKitManager: RCTEventEmitter {
|
||||||
|
|
||||||
private let healthStore = HKHealthStore()
|
private let healthStore = HKHealthStore()
|
||||||
|
|
||||||
static func moduleName() -> String! {
|
override static func moduleName() -> String! {
|
||||||
return "HealthKitManager"
|
return "HealthKitManager"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func requiresMainQueueSetup() -> Bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Types We Care About
|
// MARK: - Types We Care About
|
||||||
|
|
||||||
@@ -1703,4 +1700,95 @@ class HealthKitManager: NSObject, RCTBridgeModule {
|
|||||||
return description.lowercased()
|
return description.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Workout Observer Methods
|
||||||
|
|
||||||
|
private var workoutObserverQuery: HKObserverQuery?
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func startWorkoutObserver(
|
||||||
|
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||||
|
rejecter: @escaping RCTPromiseRejectBlock
|
||||||
|
) {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable() else {
|
||||||
|
rejecter("HEALTHKIT_NOT_AVAILABLE", "HealthKit is not available on this device", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经有观察者在运行,先停止它
|
||||||
|
if let existingQuery = workoutObserverQuery {
|
||||||
|
healthStore.stop(existingQuery)
|
||||||
|
workoutObserverQuery = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建锻炼数据观察者
|
||||||
|
let workoutType = ReadTypes.workoutType
|
||||||
|
|
||||||
|
workoutObserverQuery = HKObserverQuery(sampleType: workoutType, predicate: nil) { [weak self] (query, completionHandler, error) in
|
||||||
|
if let error = error {
|
||||||
|
print("Workout observer error: \(error.localizedDescription)")
|
||||||
|
completionHandler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Workout data updated, sending event to React Native")
|
||||||
|
// 发送事件到 React Native
|
||||||
|
self?.sendWorkoutUpdateEvent()
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用后台传递
|
||||||
|
healthStore.enableBackgroundDelivery(for: workoutType, frequency: .immediate) { (success, error) in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to enable background delivery for workouts: \(error.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
print("Background delivery for workouts enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
healthStore.execute(workoutObserverQuery!)
|
||||||
|
|
||||||
|
resolver(["success": true])
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func stopWorkoutObserver(
|
||||||
|
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||||
|
rejecter: @escaping RCTPromiseRejectBlock
|
||||||
|
) {
|
||||||
|
if let query = workoutObserverQuery {
|
||||||
|
healthStore.stop(query)
|
||||||
|
workoutObserverQuery = nil
|
||||||
|
|
||||||
|
// 禁用后台传递
|
||||||
|
healthStore.disableBackgroundDelivery(for: ReadTypes.workoutType) { (success, error) in
|
||||||
|
if let error = error {
|
||||||
|
print("Failed to disable background delivery for workouts: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver(["success": true])
|
||||||
|
} else {
|
||||||
|
resolver(["success": true]) // 即使没有查询在运行也返回成功
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendWorkoutUpdateEvent() {
|
||||||
|
// 使用 RCTEventEmitter 发送事件
|
||||||
|
sendEvent(withName: "workoutUpdate", body: [
|
||||||
|
"timestamp": Date().timeIntervalSince1970,
|
||||||
|
"type": "workout_completed"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RCTEventEmitter Overrides
|
||||||
|
|
||||||
|
override func supportedEvents() -> [String]! {
|
||||||
|
return ["workoutUpdate"]
|
||||||
|
}
|
||||||
|
|
||||||
|
override static func requiresMainQueueSetup() -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
} // end class
|
} // end class
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>processing</string>
|
<string>fetch</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>SplashScreen</string>
|
<string>SplashScreen</string>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ PODS:
|
|||||||
- EXImageLoader (6.0.0):
|
- EXImageLoader (6.0.0):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- React-Core
|
- React-Core
|
||||||
- EXNotifications (0.32.11):
|
- EXNotifications (0.32.12):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- Expo (54.0.10):
|
- Expo (54.0.13):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -45,18 +45,18 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ZXingObjC/OneD
|
- ZXingObjC/OneD
|
||||||
- ZXingObjC/PDF417
|
- ZXingObjC/PDF417
|
||||||
- ExpoFileSystem (19.0.15):
|
- ExpoFileSystem (19.0.17):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoFont (14.0.8):
|
- ExpoFont (14.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoGlassEffect (0.1.4):
|
- ExpoGlassEffect (0.1.4):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHaptics (15.0.7):
|
- ExpoHaptics (15.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoHead (6.0.8):
|
- ExpoHead (6.0.12):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- RNScreens
|
- RNScreens
|
||||||
- ExpoImage (3.0.8):
|
- ExpoImage (3.0.9):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- libavif/libdav1d
|
- libavif/libdav1d
|
||||||
- SDWebImage (~> 5.21.0)
|
- SDWebImage (~> 5.21.0)
|
||||||
@@ -71,7 +71,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoLinking (8.0.8):
|
- ExpoLinking (8.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoModulesCore (3.0.18):
|
- ExpoModulesCore (3.0.21):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -106,7 +106,7 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoUI (0.2.0-beta.4):
|
- ExpoUI (0.2.0-beta.4):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoWebBrowser (15.0.7):
|
- ExpoWebBrowser (15.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXTaskManager (14.0.7):
|
- EXTaskManager (14.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
@@ -156,8 +156,8 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- PurchasesHybridCommon (17.7.0):
|
- PurchasesHybridCommon (17.10.0):
|
||||||
- RevenueCat (= 5.39.0)
|
- RevenueCat (= 5.43.0)
|
||||||
- RCTDeprecation (0.81.4)
|
- RCTDeprecation (0.81.4)
|
||||||
- RCTRequired (0.81.4)
|
- RCTRequired (0.81.4)
|
||||||
- RCTTypeSafety (0.81.4):
|
- RCTTypeSafety (0.81.4):
|
||||||
@@ -1882,7 +1882,7 @@ PODS:
|
|||||||
- React-utils (= 0.81.4)
|
- React-utils (= 0.81.4)
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- ReactNativeDependencies (0.81.4)
|
- ReactNativeDependencies (0.81.4)
|
||||||
- RevenueCat (5.39.0)
|
- RevenueCat (5.43.0)
|
||||||
- RNCAsyncStorage (2.2.0):
|
- RNCAsyncStorage (2.2.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
@@ -1971,7 +1971,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNDeviceInfo (14.0.4):
|
- RNDeviceInfo (14.1.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNGestureHandler (2.28.0):
|
- RNGestureHandler (2.28.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
@@ -1995,10 +1995,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNPurchases (9.4.3):
|
- RNPurchases (9.5.4):
|
||||||
- PurchasesHybridCommon (= 17.7.0)
|
- PurchasesHybridCommon (= 17.10.0)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReanimated (4.1.0):
|
- RNReanimated (4.1.3):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2020,10 +2020,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated (= 4.1.0)
|
- RNReanimated/reanimated (= 4.1.3)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated (4.1.0):
|
- RNReanimated/reanimated (4.1.3):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2045,10 +2045,10 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNReanimated/reanimated/apple (= 4.1.0)
|
- RNReanimated/reanimated/apple (= 4.1.3)
|
||||||
- RNWorklets
|
- RNWorklets
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNReanimated/reanimated/apple (4.1.0):
|
- RNReanimated/reanimated/apple (4.1.3):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2143,7 +2143,7 @@ PODS:
|
|||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Sentry/HybridSDK (= 8.56.0)
|
- Sentry/HybridSDK (= 8.56.0)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNSVG (15.13.0):
|
- RNSVG (15.14.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2164,9 +2164,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNSVG/common (= 15.13.0)
|
- RNSVG/common (= 15.14.0)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNSVG/common (15.13.0):
|
- RNSVG/common (15.14.0):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2188,7 +2188,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNWorklets (0.5.1):
|
- RNWorklets (0.6.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2210,9 +2210,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNWorklets/worklets (= 0.5.1)
|
- RNWorklets/worklets (= 0.6.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNWorklets/worklets (0.5.1):
|
- RNWorklets/worklets (0.6.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2234,9 +2234,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- RNWorklets/worklets/apple (= 0.5.1)
|
- RNWorklets/worklets/apple (= 0.6.1)
|
||||||
- Yoga
|
- Yoga
|
||||||
- RNWorklets/worklets/apple (0.5.1):
|
- RNWorklets/worklets/apple (0.6.1):
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
- RCTRequired
|
- RCTRequired
|
||||||
- RCTTypeSafety
|
- RCTTypeSafety
|
||||||
@@ -2259,9 +2259,9 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- ReactNativeDependencies
|
- ReactNativeDependencies
|
||||||
- Yoga
|
- Yoga
|
||||||
- SDWebImage (5.21.2):
|
- SDWebImage (5.21.3):
|
||||||
- SDWebImage/Core (= 5.21.2)
|
- SDWebImage/Core (= 5.21.3)
|
||||||
- SDWebImage/Core (5.21.2)
|
- SDWebImage/Core (5.21.3)
|
||||||
- SDWebImageAVIFCoder (0.11.1):
|
- SDWebImageAVIFCoder (0.11.1):
|
||||||
- libavif/core (>= 0.11.0)
|
- libavif/core (>= 0.11.0)
|
||||||
- SDWebImage (~> 5.10)
|
- SDWebImage (~> 5.10)
|
||||||
@@ -2646,31 +2646,31 @@ SPEC CHECKSUMS:
|
|||||||
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
EXApplication: 296622817d459f46b6c5fe8691f4aac44d2b79e7
|
||||||
EXConstants: a95804601ee4a6aa7800645f9b070d753b1142b3
|
EXConstants: a95804601ee4a6aa7800645f9b070d753b1142b3
|
||||||
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
|
||||||
EXNotifications: 7a2975f4e282b827a0bc78bb1d232650cb569bbd
|
EXNotifications: 7cff475adb5d7a255a9ea46bbd2589cb3b454506
|
||||||
Expo: c839a13691635386c0134f204dbbaed3cebff0a8
|
Expo: 6580dbf21d94626792b38a95cddb2fb369ec6b0c
|
||||||
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
ExpoAppleAuthentication: bc9de6e9ff3340604213ab9031d4c4f7f802623e
|
||||||
ExpoAsset: 9ba6fbd677fb8e241a3899ac00fa735bc911eadf
|
ExpoAsset: 9ba6fbd677fb8e241a3899ac00fa735bc911eadf
|
||||||
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
|
ExpoBackgroundTask: e0d201d38539c571efc5f9cb661fae8ab36ed61b
|
||||||
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
ExpoBlur: 2dd8f64aa31f5d405652c21d3deb2d2588b1852f
|
||||||
ExpoCamera: e75f6807a2c047f3338bbadd101af4c71a1d13a5
|
ExpoCamera: e75f6807a2c047f3338bbadd101af4c71a1d13a5
|
||||||
ExpoFileSystem: 5fb091ea11198e109ceef2bdef2e6e66523e62c4
|
ExpoFileSystem: b79eadbda7b7f285f378f95f959cc9313a1c9c61
|
||||||
ExpoFont: 86ceec09ffed1c99cfee36ceb79ba149074901b5
|
ExpoFont: cf9d90ec1d3b97c4f513211905724c8171f82961
|
||||||
ExpoGlassEffect: 744bf0c58c26a1b0212dff92856be07b98d01d8c
|
ExpoGlassEffect: 744bf0c58c26a1b0212dff92856be07b98d01d8c
|
||||||
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
ExpoHaptics: 807476b0c39e9d82b7270349d6487928ce32df84
|
||||||
ExpoHead: 5570e5edbe54fd8f88e51e8b94bf2931caaa7363
|
ExpoHead: 7141e494b0773a8f0dc5ca3366ce91b1300f5a9d
|
||||||
ExpoImage: e88f500585913969b930e13a4be47277eb7c6de8
|
ExpoImage: 6356eb13d3a076a991cf191e4bb22cca91a8f317
|
||||||
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
ExpoImagePicker: d251aab45a1b1857e4156fed88511b278b4eee1c
|
||||||
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
ExpoKeepAwake: 1a2e820692e933c94a565ec3fbbe38ac31658ffe
|
||||||
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
ExpoLinearGradient: a464898cb95153125e3b81894fd479bcb1c7dd27
|
||||||
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
|
ExpoLinking: f051f28e50ea9269ff539317c166adec81d9342d
|
||||||
ExpoModulesCore: c03ac4bc5a83469ca1222c3954a0499cd059addf
|
ExpoModulesCore: 3a6eb12a5f4d67b2f5fc7d0bc4777b18348f2d7a
|
||||||
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
ExpoQuickActions: 31a70aa6a606128de4416a4830e09cfabfe6667f
|
||||||
ExpoSplashScreen: cbb839de72110dea1851dd3e85080b7923af2540
|
ExpoSplashScreen: cbb839de72110dea1851dd3e85080b7923af2540
|
||||||
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
ExpoSQLite: 7fa091ba5562474093fef09be644161a65e11b3f
|
||||||
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
ExpoSymbols: 1ae04ce686de719b9720453b988d8bc5bf776c68
|
||||||
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
|
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
|
||||||
ExpoUI: 5e44b62e2589b7bc8a6123943105a230c693d000
|
ExpoUI: 5e44b62e2589b7bc8a6123943105a230c693d000
|
||||||
ExpoWebBrowser: 533bc2a1b188eec1c10e4926decf658f1687b5e5
|
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
|
||||||
EXTaskManager: cf225704fab8de8794a6f57f7fa41a90c0e2cd47
|
EXTaskManager: cf225704fab8de8794a6f57f7fa41a90c0e2cd47
|
||||||
FBLazyVector: 9e0cd874afd81d9a4d36679daca991b58b260d42
|
FBLazyVector: 9e0cd874afd81d9a4d36679daca991b58b260d42
|
||||||
hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394
|
hermes-engine: 35c763d57c9832d0eef764316ca1c4d043581394
|
||||||
@@ -2679,7 +2679,7 @@ SPEC CHECKSUMS:
|
|||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||||
PurchasesHybridCommon: 6bc96162fb0c061e1980f474be618c088cfd1428
|
PurchasesHybridCommon: b7b4eafb55fbaaac19b4c36d4082657a3f0d8490
|
||||||
RCTDeprecation: 7487d6dda857ccd4cb3dd6ecfccdc3170e85dcbc
|
RCTDeprecation: 7487d6dda857ccd4cb3dd6ecfccdc3170e85dcbc
|
||||||
RCTRequired: 54128b7df8be566881d48c7234724a78cb9b6157
|
RCTRequired: 54128b7df8be566881d48c7234724a78cb9b6157
|
||||||
RCTTypeSafety: d2b07797a79e45d7b19e1cd2f53c79ab419fe217
|
RCTTypeSafety: d2b07797a79e45d7b19e1cd2f53c79ab419fe217
|
||||||
@@ -2748,20 +2748,20 @@ SPEC CHECKSUMS:
|
|||||||
ReactCodegen: a15ad48730e9fb2a51a4c9f61fe1ed253dfcf10f
|
ReactCodegen: a15ad48730e9fb2a51a4c9f61fe1ed253dfcf10f
|
||||||
ReactCommon: 149b6c05126f2e99f2ed0d3c63539369546f8cae
|
ReactCommon: 149b6c05126f2e99f2ed0d3c63539369546f8cae
|
||||||
ReactNativeDependencies: ed6d1e64802b150399f04f1d5728ec16b437251e
|
ReactNativeDependencies: ed6d1e64802b150399f04f1d5728ec16b437251e
|
||||||
RevenueCat: 4743a5eee0004e1c03eabeb3498818f902a5d622
|
RevenueCat: a51003d4cb33820cc504cf177c627832b462a98e
|
||||||
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4
|
||||||
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
RNCMaskedView: d2578d41c59b936db122b2798ba37e4722d21035
|
||||||
RNCPicker: ddce382c4b42ea2ee36dd588066f0c6d5a240707
|
RNCPicker: ddce382c4b42ea2ee36dd588066f0c6d5a240707
|
||||||
RNDateTimePicker: 7dda2673bd2a6022ea8888fe669d735b2eac0b2d
|
RNDateTimePicker: 7dda2673bd2a6022ea8888fe669d735b2eac0b2d
|
||||||
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
|
RNDeviceInfo: bcce8752b5043a623fe3c26789679b473f705d3c
|
||||||
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3
|
||||||
RNPurchases: 1bc60e3a69af65d9cfe23967328421dd1df1763c
|
RNPurchases: 2569675abdc1dbc739f2eec0fa564a112cf860de
|
||||||
RNReanimated: 8d3a14606ad49f022c17d9e12a2d339e9e5ad9b0
|
RNReanimated: 3895a29fdf77bbe2a627e1ed599a5e5d1df76c29
|
||||||
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845
|
||||||
RNSentry: dbee413744aec703b8763b620b14ed7a1e2db095
|
RNSentry: dbee413744aec703b8763b620b14ed7a1e2db095
|
||||||
RNSVG: efc8a09e4ef50e7df0dbc9327752be127a2f610c
|
RNSVG: 6c534e37eaaefe882b3f55294d0d607de20562dc
|
||||||
RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0
|
RNWorklets: 54d8dffb7f645873a58484658ddfd4bd1a9a0bc1
|
||||||
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
|
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||||
|
|||||||
1978
package-lock.json
generated
1978
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -24,22 +24,22 @@
|
|||||||
"@sentry/react-native": "~7.1.0",
|
"@sentry/react-native": "~7.1.0",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"expo": "^54.0.10",
|
"expo": "^54.0.13",
|
||||||
"expo-apple-authentication": "~8.0.7",
|
"expo-apple-authentication": "~8.0.7",
|
||||||
"expo-background-task": "~1.0.8",
|
"expo-background-task": "~1.0.8",
|
||||||
"expo-blur": "~15.0.7",
|
"expo-blur": "~15.0.7",
|
||||||
"expo-camera": "~17.0.8",
|
"expo-camera": "~17.0.8",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-font": "~14.0.8",
|
"expo-font": "~14.0.9",
|
||||||
"expo-glass-effect": "^0.1.4",
|
"expo-glass-effect": "^0.1.4",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
"expo-image": "~3.0.8",
|
"expo-image": "~3.0.9",
|
||||||
"expo-image-picker": "~17.0.8",
|
"expo-image-picker": "~17.0.8",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
"expo-notifications": "~0.32.11",
|
"expo-notifications": "~0.32.12",
|
||||||
"expo-quick-actions": "^6.0.0",
|
"expo-quick-actions": "^6.0.0",
|
||||||
"expo-router": "~6.0.8",
|
"expo-router": "~6.0.12",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-sqlite": "^16.0.8",
|
"expo-sqlite": "^16.0.8",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ export class NotificationService {
|
|||||||
if (data?.url) {
|
if (data?.url) {
|
||||||
router.push(data.url as any);
|
router.push(data.url as any);
|
||||||
}
|
}
|
||||||
|
} else if (data?.type === NotificationTypes.WORKOUT_COMPLETION) {
|
||||||
|
// 处理锻炼完成通知
|
||||||
|
console.log('用户点击了锻炼完成通知', data);
|
||||||
|
// 跳转到锻炼历史页面
|
||||||
|
router.push('/workout/history' as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +516,7 @@ export const NotificationTypes = {
|
|||||||
WATER_REMINDER: 'water_reminder',
|
WATER_REMINDER: 'water_reminder',
|
||||||
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
REGULAR_WATER_REMINDER: 'regular_water_reminder',
|
||||||
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
CHALLENGE_ENCOURAGEMENT: 'challenge_encouragement',
|
||||||
|
WORKOUT_COMPLETION: 'workout_completion',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 便捷方法
|
// 便捷方法
|
||||||
|
|||||||
173
services/workoutMonitor.ts
Normal file
173
services/workoutMonitor.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { fetchRecentWorkouts, WorkoutData } from '@/utils/health';
|
||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||||
|
import { analyzeWorkoutAndSendNotification } from './workoutNotificationService';
|
||||||
|
|
||||||
|
const { HealthKitManager } = NativeModules;
|
||||||
|
const workoutEmitter = new NativeEventEmitter(HealthKitManager);
|
||||||
|
|
||||||
|
class WorkoutMonitorService {
|
||||||
|
private static instance: WorkoutMonitorService;
|
||||||
|
private isInitialized = false;
|
||||||
|
private lastProcessedWorkoutId: string | null = null;
|
||||||
|
private processingTimeout: any = null;
|
||||||
|
private eventListenerSubscription: any = null;
|
||||||
|
|
||||||
|
static getInstance(): WorkoutMonitorService {
|
||||||
|
if (!WorkoutMonitorService.instance) {
|
||||||
|
WorkoutMonitorService.instance = new WorkoutMonitorService();
|
||||||
|
}
|
||||||
|
return WorkoutMonitorService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
console.log('锻炼监听服务已初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取上次处理的锻炼ID
|
||||||
|
await this.loadLastProcessedWorkoutId();
|
||||||
|
|
||||||
|
// 启动 iOS 原生锻炼监听器
|
||||||
|
await HealthKitManager.startWorkoutObserver();
|
||||||
|
|
||||||
|
// 监听锻炼更新事件
|
||||||
|
this.eventListenerSubscription = workoutEmitter.addListener(
|
||||||
|
'workoutUpdate',
|
||||||
|
this.handleWorkoutUpdate.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('锻炼监听服务初始化成功');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('锻炼监听服务初始化失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 停止原生监听器
|
||||||
|
await HealthKitManager.stopWorkoutObserver();
|
||||||
|
|
||||||
|
// 移除事件监听器
|
||||||
|
if (this.eventListenerSubscription) {
|
||||||
|
this.eventListenerSubscription.remove();
|
||||||
|
this.eventListenerSubscription = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
if (this.processingTimeout) {
|
||||||
|
clearTimeout(this.processingTimeout);
|
||||||
|
this.processingTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('锻炼监听服务已停止');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('停止锻炼监听服务失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLastProcessedWorkoutId(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const storedId = await AsyncStorage.getItem('@last_processed_workout_id');
|
||||||
|
this.lastProcessedWorkoutId = storedId;
|
||||||
|
console.log('上次处理的锻炼ID:', this.lastProcessedWorkoutId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载上次处理的锻炼ID失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveLastProcessedWorkoutId(workoutId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem('@last_processed_workout_id', workoutId);
|
||||||
|
this.lastProcessedWorkoutId = workoutId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存上次处理的锻炼ID失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleWorkoutUpdate(event: any): Promise<void> {
|
||||||
|
console.log('收到锻炼更新事件:', event);
|
||||||
|
|
||||||
|
// 防抖处理,避免短时间内重复处理
|
||||||
|
if (this.processingTimeout) {
|
||||||
|
clearTimeout(this.processingTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingTimeout = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await this.checkForNewWorkouts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查新锻炼失败:', error);
|
||||||
|
}
|
||||||
|
}, 5000); // 5秒延迟,确保 HealthKit 数据已完全更新
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkForNewWorkouts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('检查新的锻炼记录...');
|
||||||
|
|
||||||
|
// 获取最近1小时的锻炼记录
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
const recentWorkouts = await fetchRecentWorkouts({
|
||||||
|
startDate: oneHourAgo.toISOString(),
|
||||||
|
endDate: new Date().toISOString(),
|
||||||
|
limit: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`找到 ${recentWorkouts.length} 条最近的锻炼记录`);
|
||||||
|
|
||||||
|
// 检查是否有新的锻炼记录
|
||||||
|
for (const workout of recentWorkouts) {
|
||||||
|
if (workout.id !== this.lastProcessedWorkoutId) {
|
||||||
|
console.log('检测到新锻炼:', {
|
||||||
|
id: workout.id,
|
||||||
|
type: workout.workoutActivityTypeString,
|
||||||
|
duration: workout.duration,
|
||||||
|
startDate: workout.startDate
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.processNewWorkout(workout);
|
||||||
|
await this.saveLastProcessedWorkoutId(workout.id);
|
||||||
|
} else {
|
||||||
|
console.log('锻炼已处理过,跳过:', workout.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查新锻炼失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processNewWorkout(workout: WorkoutData): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('开始处理新锻炼:', workout.id);
|
||||||
|
|
||||||
|
// 分析锻炼并发送通知
|
||||||
|
await analyzeWorkoutAndSendNotification(workout);
|
||||||
|
|
||||||
|
console.log('新锻炼处理完成:', workout.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理新锻炼失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动触发检查(用于测试)
|
||||||
|
async manualCheck(): Promise<void> {
|
||||||
|
console.log('手动触发锻炼检查...');
|
||||||
|
await this.checkForNewWorkouts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取服务状态
|
||||||
|
getStatus(): { initialized: boolean; lastProcessedWorkoutId: string | null } {
|
||||||
|
return {
|
||||||
|
initialized: this.isInitialized,
|
||||||
|
lastProcessedWorkoutId: this.lastProcessedWorkoutId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workoutMonitorService = WorkoutMonitorService.getInstance();
|
||||||
161
services/workoutNotificationService.ts
Normal file
161
services/workoutNotificationService.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { getWorkoutTypeDisplayName, WorkoutData } from '@/utils/health';
|
||||||
|
import { getNotificationEnabled } from '@/utils/userPreferences';
|
||||||
|
import {
|
||||||
|
getWorkoutNotificationEnabled,
|
||||||
|
isNotificationTimeAllowed,
|
||||||
|
isWorkoutTypeEnabled
|
||||||
|
} from '@/utils/workoutPreferences';
|
||||||
|
import { notificationService, NotificationTypes } from './notifications';
|
||||||
|
import { getWorkoutDetailMetrics } from './workoutDetail';
|
||||||
|
|
||||||
|
interface WorkoutEncouragementMessage {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 检查用户是否启用了通用通知
|
||||||
|
const notificationsEnabled = await getNotificationEnabled();
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
console.log('用户已禁用通知,跳过锻炼结束通知');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否启用了锻炼通知
|
||||||
|
const workoutNotificationsEnabled = await getWorkoutNotificationEnabled();
|
||||||
|
if (!workoutNotificationsEnabled) {
|
||||||
|
console.log('用户已禁用锻炼通知,跳过锻炼结束通知');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查时间限制(避免深夜打扰)
|
||||||
|
const timeAllowed = await isNotificationTimeAllowed();
|
||||||
|
if (!timeAllowed) {
|
||||||
|
console.log('当前时间不适合发送通知,跳过锻炼结束通知');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查特定锻炼类型是否启用了通知
|
||||||
|
const workoutTypeEnabled = await isWorkoutTypeEnabled(workout.workoutActivityTypeString || '');
|
||||||
|
if (!workoutTypeEnabled) {
|
||||||
|
console.log('该锻炼类型已禁用通知,跳过锻炼结束通知:', workout.workoutActivityTypeString);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取详细的锻炼指标
|
||||||
|
const workoutMetrics = await getWorkoutDetailMetrics(workout);
|
||||||
|
|
||||||
|
// 生成个性化鼓励消息
|
||||||
|
const message = generateEncouragementMessage(workout, workoutMetrics);
|
||||||
|
|
||||||
|
// 发送通知
|
||||||
|
await notificationService.sendImmediateNotification({
|
||||||
|
title: message.title,
|
||||||
|
body: message.body,
|
||||||
|
data: {
|
||||||
|
type: NotificationTypes.WORKOUT_COMPLETION,
|
||||||
|
workoutId: workout.id,
|
||||||
|
workoutType: workout.workoutActivityTypeString,
|
||||||
|
duration: workout.duration,
|
||||||
|
calories: workout.totalEnergyBurned,
|
||||||
|
...message.data
|
||||||
|
},
|
||||||
|
sound: true,
|
||||||
|
priority: 'high'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('锻炼结束通知已发送:', message.title);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送锻炼结束通知失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateEncouragementMessage(
|
||||||
|
workout: WorkoutData,
|
||||||
|
metrics: any
|
||||||
|
): WorkoutEncouragementMessage {
|
||||||
|
const workoutType = getWorkoutTypeDisplayName(workout.workoutActivityTypeString);
|
||||||
|
const durationMinutes = Math.round(workout.duration / 60);
|
||||||
|
const calories = workout.totalEnergyBurned ? Math.round(workout.totalEnergyBurned) : 0;
|
||||||
|
|
||||||
|
// 基于锻炼类型和指标生成个性化消息
|
||||||
|
let title = '锻炼完成!';
|
||||||
|
let body = '';
|
||||||
|
let data: Record<string, any> = {};
|
||||||
|
|
||||||
|
switch (workout.workoutActivityTypeString?.toLowerCase()) {
|
||||||
|
case 'running':
|
||||||
|
title = '🏃♂️ 跑步完成!';
|
||||||
|
body = `太棒了!您刚刚完成了${durationMinutes}分钟的跑步,消耗了约${calories}千卡热量。`;
|
||||||
|
if (metrics.averageHeartRate) {
|
||||||
|
body += `平均心率${metrics.averageHeartRate}次/分。`;
|
||||||
|
}
|
||||||
|
body += '坚持运动让身体更健康!💪';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cycling':
|
||||||
|
title = '🚴♂️ 骑行完成!';
|
||||||
|
body = `骑行${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||||
|
if (workout.totalDistance) {
|
||||||
|
const distanceKm = (workout.totalDistance / 1000).toFixed(2);
|
||||||
|
body += `骑行距离${distanceKm}公里。`;
|
||||||
|
}
|
||||||
|
body += '享受骑行的自由吧!🌟';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'swimming':
|
||||||
|
title = '🏊♂️ 游泳完成!';
|
||||||
|
body = `游泳${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||||
|
body += '全身运动效果极佳,继续保持!💦';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'yoga':
|
||||||
|
title = '🧘♀️ 瑜伽完成!';
|
||||||
|
body = `${durationMinutes}分钟的瑜伽练习完成!提升了柔韧性和内心平静。`;
|
||||||
|
body += '继续保持这份宁静!🌸';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'functionalstrengthtraining':
|
||||||
|
case 'traditionalstrengthtraining':
|
||||||
|
title = '💪 力量训练完成!';
|
||||||
|
body = `力量训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||||
|
if (metrics.mets && metrics.mets > 6) {
|
||||||
|
body += '高强度训练,效果显著!🔥';
|
||||||
|
}
|
||||||
|
body += '肌肉正在变得更强壮!';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'highintensityintervaltraining':
|
||||||
|
title = '🔥 HIIT训练完成!';
|
||||||
|
body = `高强度间歇训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||||
|
body += '心肺功能得到有效提升,您的努力值得称赞!⚡';
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
title = '🎯 锻炼完成!';
|
||||||
|
body = `${workoutType}${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||||
|
body += '坚持运动,健康生活!🌟';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加心率区间分析(如果有心率数据)
|
||||||
|
if (metrics.heartRateZones && metrics.heartRateZones.length > 0) {
|
||||||
|
const dominantZone = metrics.heartRateZones.reduce((prev: any, current: any) =>
|
||||||
|
current.durationMinutes > prev.durationMinutes ? current : prev
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dominantZone.durationMinutes > 5) {
|
||||||
|
data.heartRateZone = dominantZone.key;
|
||||||
|
data.heartRateZoneLabel = dominantZone.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加锻炼强度评估
|
||||||
|
if (metrics.mets) {
|
||||||
|
data.intensity = metrics.mets < 3 ? 'low' : metrics.mets < 6 ? 'moderate' : 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, body, data };
|
||||||
|
}
|
||||||
152
utils/workoutPreferences.ts
Normal file
152
utils/workoutPreferences.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import AsyncStorage from '@/utils/kvStore';
|
||||||
|
|
||||||
|
const WORKOUT_NOTIFICATION_ENABLED_KEY = '@workout_notification_enabled';
|
||||||
|
const WORKOUT_NOTIFICATION_TIME_RANGE_KEY = '@workout_notification_time_range';
|
||||||
|
const WORKOUT_NOTIFICATION_TYPES_KEY = '@workout_notification_types';
|
||||||
|
|
||||||
|
export interface WorkoutNotificationPreferences {
|
||||||
|
enabled: boolean;
|
||||||
|
startTimeHour: number; // 开始时间(小时,0-23)
|
||||||
|
endTimeHour: number; // 结束时间(小时,0-23)
|
||||||
|
enabledWorkoutTypes: string[]; // 启用通知的锻炼类型
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFERENCES: WorkoutNotificationPreferences = {
|
||||||
|
enabled: true,
|
||||||
|
startTimeHour: 8, // 早上8点
|
||||||
|
endTimeHour: 22, // 晚上10点
|
||||||
|
enabledWorkoutTypes: [] // 空数组表示所有类型都启用
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取锻炼通知偏好设置
|
||||||
|
*/
|
||||||
|
export async function getWorkoutNotificationPreferences(): Promise<WorkoutNotificationPreferences> {
|
||||||
|
try {
|
||||||
|
const enabled = await AsyncStorage.getItem(WORKOUT_NOTIFICATION_ENABLED_KEY);
|
||||||
|
const timeRange = await AsyncStorage.getItem(WORKOUT_NOTIFICATION_TIME_RANGE_KEY);
|
||||||
|
const workoutTypes = await AsyncStorage.getItem(WORKOUT_NOTIFICATION_TYPES_KEY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: enabled !== 'false', // 默认启用
|
||||||
|
startTimeHour: timeRange ? JSON.parse(timeRange).start : DEFAULT_PREFERENCES.startTimeHour,
|
||||||
|
endTimeHour: timeRange ? JSON.parse(timeRange).end : DEFAULT_PREFERENCES.endTimeHour,
|
||||||
|
enabledWorkoutTypes: workoutTypes ? JSON.parse(workoutTypes) : DEFAULT_PREFERENCES.enabledWorkoutTypes
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取锻炼通知偏好设置失败:', error);
|
||||||
|
return DEFAULT_PREFERENCES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存锻炼通知偏好设置
|
||||||
|
*/
|
||||||
|
export async function saveWorkoutNotificationPreferences(preferences: Partial<WorkoutNotificationPreferences>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const currentPrefs = await getWorkoutNotificationPreferences();
|
||||||
|
const newPrefs = { ...currentPrefs, ...preferences };
|
||||||
|
|
||||||
|
if (preferences.enabled !== undefined) {
|
||||||
|
await AsyncStorage.setItem(WORKOUT_NOTIFICATION_ENABLED_KEY, preferences.enabled.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.startTimeHour !== undefined || preferences.endTimeHour !== undefined) {
|
||||||
|
const timeRange = {
|
||||||
|
start: preferences.startTimeHour ?? currentPrefs.startTimeHour,
|
||||||
|
end: preferences.endTimeHour ?? currentPrefs.endTimeHour
|
||||||
|
};
|
||||||
|
await AsyncStorage.setItem(WORKOUT_NOTIFICATION_TIME_RANGE_KEY, JSON.stringify(timeRange));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.enabledWorkoutTypes !== undefined) {
|
||||||
|
await AsyncStorage.setItem(WORKOUT_NOTIFICATION_TYPES_KEY, JSON.stringify(preferences.enabledWorkoutTypes));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('锻炼通知偏好设置已保存:', newPrefs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存锻炼通知偏好设置失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前时间是否在允许的通知时间范围内
|
||||||
|
*/
|
||||||
|
export async function isNotificationTimeAllowed(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const preferences = await getWorkoutNotificationPreferences();
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
|
||||||
|
// 处理跨天的情况(例如 22:00 - 8:00)
|
||||||
|
if (preferences.startTimeHour <= preferences.endTimeHour) {
|
||||||
|
// 正常情况,如 8:00 - 22:00
|
||||||
|
return currentHour >= preferences.startTimeHour && currentHour <= preferences.endTimeHour;
|
||||||
|
} else {
|
||||||
|
// 跨天情况,如 22:00 - 8:00
|
||||||
|
return currentHour >= preferences.startTimeHour || currentHour <= preferences.endTimeHour;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查通知时间失败:', error);
|
||||||
|
return true; // 默认允许
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查特定锻炼类型是否启用了通知
|
||||||
|
*/
|
||||||
|
export async function isWorkoutTypeEnabled(workoutType: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const preferences = await getWorkoutNotificationPreferences();
|
||||||
|
|
||||||
|
// 如果启用的类型列表为空,表示所有类型都启用
|
||||||
|
if (preferences.enabledWorkoutTypes.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferences.enabledWorkoutTypes.includes(workoutType);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查锻炼类型通知设置失败:', error);
|
||||||
|
return true; // 默认允许
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取锻炼通知的简化状态(仅检查是否启用)
|
||||||
|
*/
|
||||||
|
export async function getWorkoutNotificationEnabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const preferences = await getWorkoutNotificationPreferences();
|
||||||
|
return preferences.enabled;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取锻炼通知启用状态失败:', error);
|
||||||
|
return true; // 默认启用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置锻炼通知启用状态
|
||||||
|
*/
|
||||||
|
export async function setWorkoutNotificationEnabled(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await saveWorkoutNotificationPreferences({ enabled });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置锻炼通知启用状态失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置锻炼通知偏好设置为默认值
|
||||||
|
*/
|
||||||
|
export async function resetWorkoutNotificationPreferences(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.removeItem(WORKOUT_NOTIFICATION_ENABLED_KEY);
|
||||||
|
await AsyncStorage.removeItem(WORKOUT_NOTIFICATION_TIME_RANGE_KEY);
|
||||||
|
await AsyncStorage.removeItem(WORKOUT_NOTIFICATION_TYPES_KEY);
|
||||||
|
console.log('锻炼通知偏好设置已重置为默认值');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重置锻炼通知偏好设置失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
utils/workoutTestHelper.ts
Normal file
198
utils/workoutTestHelper.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { workoutMonitorService } from '@/services/workoutMonitor';
|
||||||
|
import { WorkoutData } from '@/utils/health';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 锻炼测试工具
|
||||||
|
* 用于开发和测试锻炼监听功能
|
||||||
|
*/
|
||||||
|
export class WorkoutTestHelper {
|
||||||
|
/**
|
||||||
|
* 模拟一个锻炼完成事件
|
||||||
|
*/
|
||||||
|
static async simulateWorkoutCompletion(): Promise<void> {
|
||||||
|
console.log('=== 开始模拟锻炼完成事件 ===');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 手动触发锻炼检查
|
||||||
|
await workoutMonitorService.manualCheck();
|
||||||
|
console.log('✅ 锻炼检查已手动触发');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 模拟锻炼完成事件失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取锻炼监听服务状态
|
||||||
|
*/
|
||||||
|
static getWorkoutMonitorStatus(): any {
|
||||||
|
return workoutMonitorService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试用的模拟锻炼数据
|
||||||
|
*/
|
||||||
|
static createMockWorkout(type: string = 'running'): WorkoutData {
|
||||||
|
const now = new Date();
|
||||||
|
const duration = 30 * 60; // 30分钟
|
||||||
|
const startTime = new Date(now.getTime() - duration * 1000);
|
||||||
|
|
||||||
|
const workoutTypes: Record<string, number> = {
|
||||||
|
running: 37,
|
||||||
|
cycling: 13,
|
||||||
|
swimming: 46,
|
||||||
|
yoga: 57,
|
||||||
|
functionalstrengthtraining: 20,
|
||||||
|
traditionalstrengthtraining: 50,
|
||||||
|
highintensityintervaltraining: 63,
|
||||||
|
walking: 52,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `mock-workout-${Date.now()}`,
|
||||||
|
startDate: startTime.toISOString(),
|
||||||
|
endDate: now.toISOString(),
|
||||||
|
duration: duration,
|
||||||
|
workoutActivityType: workoutTypes[type] || 37,
|
||||||
|
workoutActivityTypeString: type,
|
||||||
|
totalEnergyBurned: Math.round(Math.random() * 300 + 100), // 100-400千卡
|
||||||
|
totalDistance: type === 'running' || type === 'cycling' ? Math.round(Math.random() * 10000 + 1000) : undefined,
|
||||||
|
averageHeartRate: Math.round(Math.random() * 50 + 120), // 120-170次/分
|
||||||
|
source: {
|
||||||
|
name: 'Test App',
|
||||||
|
bundleIdentifier: 'com.test.app'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
HKAverageMETs: Math.random() * 10 + 5 // 5-15 METs
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试不同类型的锻炼通知
|
||||||
|
*/
|
||||||
|
static async testWorkoutNotifications(): Promise<void> {
|
||||||
|
console.log('=== 开始测试不同类型锻炼通知 ===');
|
||||||
|
|
||||||
|
const workoutTypes = ['running', 'cycling', 'swimming', 'yoga', 'functionalstrengthtraining', 'highintensityintervaltraining'];
|
||||||
|
|
||||||
|
for (const type of workoutTypes) {
|
||||||
|
console.log(`--- 测试 ${type} 锻炼通知 ---`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 这里需要导入通知服务来直接测试
|
||||||
|
const { analyzeWorkoutAndSendNotification } = await import('@/services/workoutNotificationService');
|
||||||
|
const mockWorkout = this.createMockWorkout(type);
|
||||||
|
|
||||||
|
await analyzeWorkoutAndSendNotification(mockWorkout);
|
||||||
|
|
||||||
|
console.log(`✅ ${type} 锻炼通知测试成功`);
|
||||||
|
|
||||||
|
// 等待一段时间再测试下一个
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ${type} 锻炼通知测试失败:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== 锻炼通知测试完成 ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试偏好设置功能
|
||||||
|
*/
|
||||||
|
static async testPreferences(): Promise<void> {
|
||||||
|
console.log('=== 开始测试偏好设置功能 ===');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
getWorkoutNotificationPreferences,
|
||||||
|
saveWorkoutNotificationPreferences,
|
||||||
|
isNotificationTimeAllowed,
|
||||||
|
isWorkoutTypeEnabled
|
||||||
|
} = await import('@/utils/workoutPreferences');
|
||||||
|
|
||||||
|
// 获取当前设置
|
||||||
|
const currentPrefs = await getWorkoutNotificationPreferences();
|
||||||
|
console.log('当前偏好设置:', currentPrefs);
|
||||||
|
|
||||||
|
// 测试时间检查
|
||||||
|
const timeAllowed = await isNotificationTimeAllowed();
|
||||||
|
console.log('当前时间是否允许通知:', timeAllowed);
|
||||||
|
|
||||||
|
// 测试锻炼类型检查
|
||||||
|
const runningEnabled = await isWorkoutTypeEnabled('running');
|
||||||
|
console.log('跑步通知是否启用:', runningEnabled);
|
||||||
|
|
||||||
|
// 临时修改设置
|
||||||
|
await saveWorkoutNotificationPreferences({
|
||||||
|
enabled: true,
|
||||||
|
startTimeHour: 9,
|
||||||
|
endTimeHour: 21,
|
||||||
|
enabledWorkoutTypes: ['running', 'cycling']
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('临时设置已保存');
|
||||||
|
|
||||||
|
// 恢复原始设置
|
||||||
|
await saveWorkoutNotificationPreferences(currentPrefs);
|
||||||
|
console.log('原始设置已恢复');
|
||||||
|
|
||||||
|
console.log('✅ 偏好设置功能测试完成');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 偏好设置功能测试失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行完整的测试套件
|
||||||
|
*/
|
||||||
|
static async runFullTestSuite(): Promise<void> {
|
||||||
|
console.log('🧪 开始运行锻炼监听功能完整测试套件');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 检查服务状态
|
||||||
|
console.log('\n1. 检查服务状态...');
|
||||||
|
const status = this.getWorkoutMonitorStatus();
|
||||||
|
console.log('服务状态:', status);
|
||||||
|
|
||||||
|
// 2. 测试偏好设置
|
||||||
|
console.log('\n2. 测试偏好设置...');
|
||||||
|
await this.testPreferences();
|
||||||
|
|
||||||
|
// 3. 测试通知功能
|
||||||
|
console.log('\n3. 测试通知功能...');
|
||||||
|
await this.testWorkoutNotifications();
|
||||||
|
|
||||||
|
// 4. 测试手动触发
|
||||||
|
console.log('\n4. 测试手动触发...');
|
||||||
|
await this.simulateWorkoutCompletion();
|
||||||
|
|
||||||
|
console.log('\n🎉 完整测试套件运行完成!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 测试套件运行失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者调试函数
|
||||||
|
* 可以在开发者控制台中调用
|
||||||
|
*/
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
testWorkoutNotifications: () => Promise<void>;
|
||||||
|
testWorkoutPreferences: () => Promise<void>;
|
||||||
|
simulateWorkoutCompletion: () => Promise<void>;
|
||||||
|
runWorkoutTestSuite: () => Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在开发环境中暴露调试函数
|
||||||
|
if (__DEV__) {
|
||||||
|
// 这些函数可以在开发者控制台中调用
|
||||||
|
// 例如: window.testWorkoutNotifications()
|
||||||
|
|
||||||
|
// 注意:这些函数需要在实际运行环境中绑定
|
||||||
|
// 可以在应用的初始化代码中添加:
|
||||||
|
// window.testWorkoutNotifications = WorkoutTestHelper.testWorkoutNotifications;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user