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

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