2026-03-27 17:12:19 +08:00
|
|
|
import React, { useEffect, useRef } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
View,
|
|
|
|
|
Text,
|
|
|
|
|
StyleSheet,
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
Image,
|
|
|
|
|
Animated,
|
|
|
|
|
Modal,
|
|
|
|
|
StatusBar,
|
|
|
|
|
Dimensions,
|
2026-04-04 08:01:45 +08:00
|
|
|
Platform,
|
2026-03-27 17:12:19 +08:00
|
|
|
} from 'react-native';
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
import { callStore } from '../../stores/callStore';
|
|
|
|
|
|
|
|
|
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
|
|
|
|
|
|
|
|
|
const IncomingCallModal: React.FC = () => {
|
|
|
|
|
const incomingCall = callStore((s) => s.incomingCall);
|
|
|
|
|
const acceptCall = callStore((s) => s.acceptCall);
|
|
|
|
|
const rejectCall = callStore((s) => s.rejectCall);
|
|
|
|
|
|
|
|
|
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
|
|
|
const rippleAnim = useRef(new Animated.Value(0)).current;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (incomingCall) {
|
2026-04-04 08:01:45 +08:00
|
|
|
if (Platform.OS === 'web') {
|
|
|
|
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
|
|
|
active?.blur?.();
|
|
|
|
|
}
|
2026-03-27 17:12:19 +08:00
|
|
|
const pulse = Animated.loop(
|
|
|
|
|
Animated.sequence([
|
|
|
|
|
Animated.timing(pulseAnim, {
|
|
|
|
|
toValue: 1.06,
|
|
|
|
|
duration: 1000,
|
|
|
|
|
useNativeDriver: true,
|
|
|
|
|
}),
|
|
|
|
|
Animated.timing(pulseAnim, {
|
|
|
|
|
toValue: 1,
|
|
|
|
|
duration: 1000,
|
|
|
|
|
useNativeDriver: true,
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const ripple = Animated.loop(
|
|
|
|
|
Animated.sequence([
|
|
|
|
|
Animated.timing(rippleAnim, {
|
|
|
|
|
toValue: 1,
|
|
|
|
|
duration: 2000,
|
|
|
|
|
useNativeDriver: true,
|
|
|
|
|
}),
|
|
|
|
|
Animated.timing(rippleAnim, {
|
|
|
|
|
toValue: 0,
|
|
|
|
|
duration: 0,
|
|
|
|
|
useNativeDriver: true,
|
|
|
|
|
}),
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Animated.parallel([pulse, ripple]).start();
|
|
|
|
|
return () => {
|
|
|
|
|
pulse.stop();
|
|
|
|
|
ripple.stop();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}, [incomingCall, pulseAnim, rippleAnim]);
|
|
|
|
|
|
|
|
|
|
const handleAccept = () => {
|
|
|
|
|
acceptCall();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleReject = () => {
|
|
|
|
|
rejectCall();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!incomingCall) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
visible={!!incomingCall}
|
|
|
|
|
transparent
|
|
|
|
|
animationType="fade"
|
|
|
|
|
statusBarTranslucent
|
|
|
|
|
>
|
|
|
|
|
<View style={styles.overlay}>
|
|
|
|
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
|
|
|
|
|
|
|
|
|
{/* Avatar with ripple effect - positioned in upper area */}
|
|
|
|
|
<View style={styles.avatarSection}>
|
|
|
|
|
{/* Ripple rings */}
|
|
|
|
|
{[0, 1, 2].map((i) => (
|
|
|
|
|
<Animated.View
|
|
|
|
|
key={i}
|
|
|
|
|
style={[
|
|
|
|
|
styles.rippleRing,
|
|
|
|
|
{
|
|
|
|
|
transform: [
|
|
|
|
|
{
|
|
|
|
|
scale: rippleAnim.interpolate({
|
|
|
|
|
inputRange: [0, 1],
|
|
|
|
|
outputRange: [1, 1.5 + i * 0.2],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
opacity: rippleAnim.interpolate({
|
|
|
|
|
inputRange: [0, 0.4, 1],
|
|
|
|
|
outputRange: [0.25, 0.12, 0],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<Animated.View
|
|
|
|
|
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
|
|
|
|
|
>
|
|
|
|
|
{incomingCall.callerAvatar ? (
|
|
|
|
|
<Image
|
|
|
|
|
source={{ uri: incomingCall.callerAvatar }}
|
|
|
|
|
style={styles.avatar}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
|
|
|
|
<Text style={styles.avatarText}>
|
|
|
|
|
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
|
|
|
|
|
</Text>
|
|
|
|
|
</View>
|
|
|
|
|
)}
|
|
|
|
|
</Animated.View>
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
{/* Caller info */}
|
|
|
|
|
<View style={styles.infoSection}>
|
|
|
|
|
<Text style={styles.callerName} numberOfLines={1}>
|
|
|
|
|
{incomingCall.callerName || '未知用户'}
|
|
|
|
|
</Text>
|
2026-03-28 00:56:52 +08:00
|
|
|
<Text style={styles.callType}>
|
|
|
|
|
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
|
|
|
|
|
</Text>
|
2026-03-27 17:12:19 +08:00
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
{/* Bottom controls */}
|
|
|
|
|
<View style={styles.controls}>
|
|
|
|
|
{/* Decline */}
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
style={styles.actionButton}
|
|
|
|
|
onPress={handleReject}
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
>
|
|
|
|
|
<View style={styles.declineCircle}>
|
|
|
|
|
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
|
|
|
|
|
</View>
|
|
|
|
|
<Text style={styles.actionLabel}>拒绝</Text>
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
{/* Accept */}
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
style={styles.actionButton}
|
|
|
|
|
onPress={handleAccept}
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
>
|
|
|
|
|
<View style={styles.acceptCircle}>
|
|
|
|
|
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
|
|
|
|
|
</View>
|
|
|
|
|
<Text style={styles.actionLabel}>接听</Text>
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
</View>
|
|
|
|
|
</View>
|
|
|
|
|
</Modal>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const AVATAR_SIZE = 100;
|
|
|
|
|
const ACTION_CIRCLE_SIZE = 60;
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
overlay: {
|
|
|
|
|
flex: 1,
|
|
|
|
|
backgroundColor: '#0D0D0D',
|
|
|
|
|
},
|
|
|
|
|
avatarSection: {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '18%',
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
height: AVATAR_SIZE + 60,
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
},
|
|
|
|
|
rippleRing: {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
width: AVATAR_SIZE + 16,
|
|
|
|
|
height: AVATAR_SIZE + 16,
|
|
|
|
|
borderRadius: (AVATAR_SIZE + 16) / 2,
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
borderColor: '#4CD964',
|
|
|
|
|
},
|
|
|
|
|
avatarContainer: {
|
|
|
|
|
width: AVATAR_SIZE + 8,
|
|
|
|
|
height: AVATAR_SIZE + 8,
|
|
|
|
|
borderRadius: (AVATAR_SIZE + 8) / 2,
|
|
|
|
|
backgroundColor: '#4CD964',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
},
|
|
|
|
|
avatar: {
|
|
|
|
|
width: AVATAR_SIZE,
|
|
|
|
|
height: AVATAR_SIZE,
|
|
|
|
|
borderRadius: AVATAR_SIZE / 2,
|
|
|
|
|
backgroundColor: '#3A3A5C',
|
|
|
|
|
},
|
|
|
|
|
avatarPlaceholder: {
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
},
|
|
|
|
|
avatarText: {
|
|
|
|
|
fontSize: 36,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
},
|
|
|
|
|
infoSection: {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: '42%',
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
paddingHorizontal: 40,
|
|
|
|
|
},
|
|
|
|
|
callerName: {
|
|
|
|
|
fontSize: 24,
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
color: '#FFFFFF',
|
|
|
|
|
marginBottom: 6,
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
maxWidth: 280,
|
|
|
|
|
},
|
|
|
|
|
callType: {
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
color: 'rgba(255, 255, 255, 0.5)',
|
|
|
|
|
letterSpacing: 0.3,
|
|
|
|
|
},
|
|
|
|
|
controls: {
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
bottom: '12%',
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
gap: 60,
|
|
|
|
|
},
|
|
|
|
|
actionButton: {
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
width: 80,
|
|
|
|
|
},
|
|
|
|
|
declineCircle: {
|
|
|
|
|
width: ACTION_CIRCLE_SIZE,
|
|
|
|
|
height: ACTION_CIRCLE_SIZE,
|
|
|
|
|
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
|
|
|
|
backgroundColor: '#E54D42',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
},
|
|
|
|
|
acceptCircle: {
|
|
|
|
|
width: ACTION_CIRCLE_SIZE,
|
|
|
|
|
height: ACTION_CIRCLE_SIZE,
|
|
|
|
|
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
|
|
|
|
backgroundColor: '#4CD964',
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
},
|
|
|
|
|
actionLabel: {
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: 'rgba(255, 255, 255, 0.6)',
|
|
|
|
|
marginTop: 10,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default IncomingCallModal;
|