Files
frontend/src/components/call/IncomingCallModal.tsx

277 lines
6.8 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Animated,
Modal,
StatusBar,
Dimensions,
} 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) {
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>
<Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
</Text>
</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;