feat(workout): 新增锻炼结束监听和个性化通知功能

实现了iOS HealthKit锻炼数据实时监听,当用户完成锻炼时自动发送个性化鼓励通知。包括锻炼类型筛选、时间范围控制、用户偏好设置等完整功能,并提供了测试工具和详细文档。
This commit is contained in:
richarjiang
2025-10-13 10:05:02 +08:00
parent 12883c5410
commit 971aebd560
18 changed files with 2210 additions and 1264 deletions

View File

@@ -5,3 +5,5 @@
- 遇到比较复杂的页面,尽量使用可以复用的组件 - 遇到比较复杂的页面,尽量使用可以复用的组件
- 不要尝试使用 `npm run ios` 命令 - 不要尝试使用 `npm run ios` 命令

View File

@@ -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>
{isLgAvaliable ? (
<TouchableOpacity onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<GlassView style={styles.editButtonGlass}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}> <TouchableOpacity style={styles.editButton} onPress={() => pushIfAuthedElseLogin('/profile/edit')}>
<Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text> <Text style={styles.editButtonText}>{isLoggedIn ? '编辑' : '登录'}</Text>
</TouchableOpacity> </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',
}, },

View File

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

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

Binary file not shown.

View File

@@ -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,6 +134,19 @@ export function WeightHistoryCard() {
style={styles.iconSquare} style={styles.iconSquare}
/> />
<Text style={styles.cardTitle}></Text> <Text style={styles.cardTitle}></Text>
{isLgAvaliable ? (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
navigateToCoach();
}}
activeOpacity={0.8}
>
<GlassView style={styles.addButtonGlass}>
<Ionicons name="add" size={18} color={Colors.light.primary} />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity <TouchableOpacity
style={styles.addButton} style={styles.addButton}
onPress={(e) => { onPress={(e) => {
@@ -143,6 +157,7 @@ export function WeightHistoryCard() {
> >
<Ionicons name="add" size={18} color={Colors.light.primary} /> <Ionicons name="add" size={18} color={Colors.light.primary} />
</TouchableOpacity> </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',
}, },

View 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
- 初始版本发布
- 支持基本的锻炼结束监听和通知
- 完整的用户偏好设置
- 测试工具和文档

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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
View 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();

View 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
View 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
View 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;
}