feat(workout): 新增锻炼结束监听和个性化通知功能
实现了iOS HealthKit锻炼数据实时监听,当用户完成锻炼时自动发送个性化鼓励通知。包括锻炼类型筛选、时间范围控制、用户偏好设置等完整功能,并提供了测试工具和详细文档。
This commit is contained in:
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user