feat(push): 新增iOS APNs推送通知功能

- 添加推送通知管理器和设备令牌管理
- 实现推送通知权限请求和令牌注册
- 新增推送通知设置页面
- 集成推送通知初始化到应用启动流程
- 添加推送通知API服务和本地存储管理
- 更新个人页面添加推送通知设置入口
This commit is contained in:
richarjiang
2025-10-14 19:25:35 +08:00
parent 435f5cc65c
commit 6cdd2fdf9c
9 changed files with 1263 additions and 2 deletions

View File

@@ -0,0 +1,328 @@
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { getTokenStatusDescription, isPushNotificationAvailable, usePushNotifications } from '@/hooks/usePushNotifications';
import { useThemeColor } from '@/hooks/useThemeColor';
import { TokenStatus } from '@/services/pushNotificationManager';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
export default function PushNotificationSettingsScreen() {
const backgroundColor = useThemeColor({}, 'background');
const textColor = useThemeColor({}, 'text');
const borderColor = useThemeColor({}, 'border');
const primaryColor = useThemeColor({}, 'primary');
const {
isInitialized,
tokenStatus,
isLoading,
registerToken,
getCurrentToken,
updateTokenStatus,
clearAllData,
} = usePushNotifications();
const [isRegistering, setIsRegistering] = useState(false);
const [currentToken, setCurrentToken] = useState<string | null>(null);
useEffect(() => {
updateTokenStatus();
setCurrentToken(getCurrentToken());
}, [isInitialized, tokenStatus]);
const handleRegisterToken = async () => {
setIsRegistering(true);
try {
const success = await registerToken();
if (success) {
Alert.alert('成功', '设备令牌注册成功');
} else {
Alert.alert('失败', '设备令牌注册失败,请检查网络连接和权限设置');
}
} catch (error) {
console.error('注册设备令牌失败:', error);
Alert.alert('错误', '注册设备令牌时发生错误');
} finally {
setIsRegistering(false);
}
};
const handleClearData = () => {
Alert.alert(
'确认清除',
'这将清除所有本地推送通知数据,包括设备令牌。确定要继续吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '确定',
style: 'destructive',
onPress: async () => {
try {
await clearAllData();
Alert.alert('成功', '推送通知数据已清除');
} catch (error) {
console.error('清除数据失败:', error);
Alert.alert('错误', '清除数据时发生错误');
}
},
},
]
);
};
const copyTokenToClipboard = () => {
if (currentToken) {
// 这里可以使用 Clipboard API 复制到剪贴板
Alert.alert('令牌已复制', '设备令牌已复制到剪贴板(功能待实现)');
}
};
const getStatusColor = () => {
switch (tokenStatus) {
case TokenStatus.REGISTERED:
return '#4CAF50';
case TokenStatus.GRANTED:
return '#2196F3';
case TokenStatus.DENIED:
return '#F44336';
case TokenStatus.FAILED:
return '#FF9800';
default:
return '#9E9E9E';
}
};
if (isLoading) {
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={primaryColor} />
<ThemedText style={styles.loadingText}>...</ThemedText>
</View>
</ThemedView>
);
}
return (
<ThemedView style={[styles.container, { backgroundColor }]}>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator={false}>
<View style={styles.header}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}>
</ThemedText>
</View>
<View style={[styles.card, { borderColor }]}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.statusRow}>
<ThemedText style={styles.statusLabel}>:</ThemedText>
<View style={styles.statusValue}>
<Text style={[styles.statusText, { color: isInitialized ? '#4CAF50' : '#F44336' }]}>
{isInitialized ? '已初始化' : '未初始化'}
</Text>
</View>
</View>
<View style={styles.statusRow}>
<ThemedText style={styles.statusLabel}>:</ThemedText>
<View style={styles.statusValue}>
<Text style={[styles.statusText, { color: getStatusColor() }]}>
{getTokenStatusDescription(tokenStatus)}
</Text>
</View>
</View>
<View style={styles.statusRow}>
<ThemedText style={styles.statusLabel}>:</ThemedText>
<View style={styles.statusValue}>
<Text style={[styles.statusText, { color: isPushNotificationAvailable(tokenStatus) ? '#4CAF50' : '#F44336' }]}>
{isPushNotificationAvailable(tokenStatus) ? '是' : '否'}
</Text>
</View>
</View>
</View>
{currentToken && (
<View style={[styles.card, { borderColor }]}>
<ThemedText style={styles.cardTitle}></ThemedText>
<View style={styles.tokenContainer}>
<Text style={[styles.tokenText, { color: textColor }]}>
{currentToken.substring(0, 20)}...{currentToken.substring(currentToken.length - 10)}
</Text>
<TouchableOpacity
style={[styles.copyButton, { borderColor }]}
onPress={copyTokenToClipboard}
>
<Text style={[styles.copyButtonText, { color: primaryColor }]}></Text>
</TouchableOpacity>
</View>
</View>
)}
<View style={[styles.card, { borderColor }]}>
<ThemedText style={styles.cardTitle}></ThemedText>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: primaryColor }]}
onPress={handleRegisterToken}
disabled={isRegistering}
>
{isRegistering ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.actionButtonText}></Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.secondaryButton, { borderColor }]}
onPress={() => updateTokenStatus()}
>
<Text style={[styles.secondaryButtonText, { color: primaryColor }]}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.dangerButton]}
onPress={handleClearData}
>
<Text style={styles.dangerButtonText}></Text>
</TouchableOpacity>
</View>
<View style={[styles.card, { borderColor }]}>
<ThemedText style={styles.cardTitle}></ThemedText>
<ThemedText style={styles.description}>
iOS设备上使用{'\n'}
{'\n'}
"权限被拒绝"{'\n'}
</ThemedText>
</View>
</ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
padding: 16,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
header: {
marginBottom: 24,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
opacity: 0.7,
},
card: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: 12,
padding: 16,
marginBottom: 16,
borderWidth: 1,
},
cardTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
statusRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
statusLabel: {
fontSize: 16,
flex: 1,
},
statusValue: {
flex: 1,
alignItems: 'flex-end',
},
statusText: {
fontSize: 16,
fontWeight: '500',
},
tokenContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
tokenText: {
fontSize: 14,
fontFamily: 'monospace',
flex: 1,
marginRight: 12,
},
copyButton: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
borderWidth: 1,
},
copyButtonText: {
fontSize: 14,
fontWeight: '500',
},
actionButton: {
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
marginBottom: 12,
},
actionButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
secondaryButton: {
backgroundColor: 'transparent',
borderWidth: 1,
},
secondaryButtonText: {
fontSize: 16,
fontWeight: '600',
},
dangerButton: {
backgroundColor: '#F44336',
},
dangerButtonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
description: {
fontSize: 14,
lineHeight: 20,
},
});