Files
digital-pilates/app/workout/notification-settings.tsx
richarjiang 971aebd560 feat(workout): 新增锻炼结束监听和个性化通知功能
实现了iOS HealthKit锻炼数据实时监听,当用户完成锻炼时自动发送个性化鼓励通知。包括锻炼类型筛选、时间范围控制、用户偏好设置等完整功能,并提供了测试工具和详细文档。
2025-10-13 10:05:02 +08:00

339 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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