enx-uikit-react-native
Version:
It is a react native component for Enablex users.
796 lines (734 loc) • 27.1 kB
JSX
/**
* EnxAudioOnlyView.tsx — WhatsApp-style audio-only call screen for React Native
*
* Dependencies (install once per project):
* npm install react-native-linear-gradient
* # Expo:
* npx expo install expo-linear-gradient
*
* Usage:
* const ref = useRef<EnxAudioOnlyViewRef>(null);
*
* <EnxAudioOnlyView
* ref={ref}
* onMuteToggle={isMuted => ...}
* onSpeakerToggle={isSpeaker => ...}
* onDisconnect={() => ...}
* />
*
* ref.current?.configure('John', 'Doe', 'Calling…')
* ref.current?.setSpeaking(true) // start / stop avatar pulse
* ref.current?.setUserVisible(false) // switch to animated "Calling…" label
* ref.current?.setUserVisible(true) // switch back to name + avatar
*/
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import {
Animated,
Dimensions,
Easing,
Image,
Platform,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
// ─── If react-native-linear-gradient is unavailable, fall back to a plain View.
let LinearGradient;
try {
LinearGradient = require('react-native-linear-gradient').default;
} catch (error) {
LinearGradient = ({ children, style }) => <View style={style}>{children}</View>;
}
const AUDIO_ON_ICON = require('../image_asset/audio_on.png');
const AUDIO_OFF_ICON = require('../image_asset/audio_off.png');
const SPEAKER_ICON = require('../image_asset/speaker_off.png');
const EARPIECE_ICON = require('../image_asset/earpic.png');
// ─── Design constants ─────────────────────────────────────────────────────────
const BASE_AVATAR_SIZE = 120;
const BASE_BUTTON_SIZE = 64;
const ACTIVE_COLOR = '#128C7E';
const RING_ALPHAS = [0.35, 0.22, 0.12];
const RING_DELAYS = [0, 220, 440]; // stagger in ms
const HEADER_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
const DEFAULT_AUDIO_VIEW_CONFIG = {
gradientLayer1: '#0A3733',
gradientLayer2: '#031412',
nameTextColor: '#FFFFFF',
sortNameTextColor: '#FFFFFF',
sortNameBGColor: ACTIVE_COLOR,
statusTextColor: '#D1D5DB',
controlCardColor: 'rgba(0,0,0,0.8)',
controlButtonColor: '#1F1F1F',
activeControlButtonColor: '#063B34',
ringBackgroundColor: 'rgba(255,255,255,0.08)',
ringBorderColor: 'rgba(255,255,255,0.18)',
};
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const normalizeColor = (value, fallback) => {
if (typeof value === 'number') {
const color = value >>> 0;
const alpha = ((color >>> 24) & 255) / 255;
const red = (color >>> 16) & 255;
const green = (color >>> 8) & 255;
const blue = color & 255;
return `rgba(${red},${green},${blue},${Number(alpha.toFixed(3))})`;
}
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
return fallback;
};
const resolveAudioViewConfig = config => {
const mergedConfig = { ...DEFAULT_AUDIO_VIEW_CONFIG, ...(config || {}) };
return Object.keys(DEFAULT_AUDIO_VIEW_CONFIG).reduce((resolvedConfig, key) => {
resolvedConfig[key] = normalizeColor(mergedConfig[key], DEFAULT_AUDIO_VIEW_CONFIG[key]);
return resolvedConfig;
}, {});
};
const getResponsiveLayout = (width, height) => {
const shortSide = Math.min(width || 360, height || 640);
const usableHeight = height || 640;
const compact = usableHeight < 620 || shortSide < 360;
const scale = clamp(Math.min(shortSide / 390, usableHeight / 760), 0.74, 1.22);
const avatarSize = Math.round(BASE_AVATAR_SIZE * scale);
const ringStep = Math.round((compact ? 18 : 24) * scale);
const ringSizes = RING_ALPHAS.map((_, i) => avatarSize + (i + 1) * ringStep * 2);
const containerSize = ringSizes[ringSizes.length - 1] + Math.round(16 * scale);
const buttonSize = Math.round(BASE_BUTTON_SIZE * clamp(scale, 0.78, 1.05));
return {
avatarSize,
buttonSize,
ringSizes,
containerSize,
topPadding: compact ? 8 : Math.round(18 * scale),
bottomPadding: compact ? 10 : Math.round(18 * scale),
cardPaddingVertical: compact ? 10 : Math.round(12 * scale),
cardPaddingHorizontal: compact ? 12 : Math.round(16 * scale),
cardRadius: Math.round(22 * scale),
cardWidth: width > 520 ? 420 : Math.max(300, Math.round((width || 360) * 0.92)),
nameFontSize: Math.round(28 * clamp(scale, 0.82, 1.12)),
statusFontSize: Math.round(15 * clamp(scale, 0.86, 1.08)),
labelFontSize: Math.round(12 * clamp(scale, 0.9, 1.05)),
iconFontSize: Math.round(20 * clamp(scale, 0.9, 1.08)),
initialsFontSize: Math.round(42 * clamp(scale, 0.82, 1.12)),
avatarMarginTop: compact ? 4 : Math.round(10 * scale),
};
};
const getNameParts = user => {
const displayName = String(
user?.name ||
user?.username ||
user?.userName ||
user?.clientName ||
user?.user_ref ||
''
).trim();
const [first = '', ...rest] = displayName.split(/\s+/);
return {
firstName: first,
lastName: rest.join(' '),
};
};
// ─── Icon components ───────────────────────────────────────────────────────
const MicIcon = ({
color = 'white',
muted = false,
size = 20,
}) => (
<View style={styles.iconContainer}>
<Image
source={muted ? AUDIO_OFF_ICON : AUDIO_ON_ICON}
style={[styles.imageIcon, { width: size + 4, height: size + 4, tintColor: color }]}
resizeMode="contain"
/>
</View>
);
const SpeakerIcon = ({
color = 'white',
active = false,
size = 20,
}) => (
<View style={styles.iconContainer}>
<Image
source={active ? SPEAKER_ICON : EARPIECE_ICON}
style={[styles.imageIcon, { width: size + 4, height: size + 4, tintColor: color }]}
resizeMode="contain"
/>
</View>
);
const PhoneDownIcon = ({ size = 20 }) => (
<View style={styles.iconContainer}>
<Text style={[styles.iconText, { color: 'white', fontSize: size, lineHeight: size + 4 }]}>📞</Text>
</View>
);
// ─── Main component ────────────────────────────────────────────────────────────
const EnxAudioOnlyView = forwardRef(
({ onMuteToggle, onSpeakerToggle, onDisconnect, audioViewConfig }, ref) => {
const { width, height } = Dimensions.get('window');
const statusBarHeight = Platform.OS === 'android' ? StatusBar.currentHeight || 0 : 0;
const usableHeight = Math.max(height - statusBarHeight - HEADER_HEIGHT, 0);
const [layoutSize, setLayoutSize] = useState({ width, height: usableHeight });
const responsiveLayout = getResponsiveLayout(layoutSize.width, layoutSize.height);
const resolvedAudioConfig = resolveAudioViewConfig(audioViewConfig);
// ── UI state ─────────────────────────────────────────────────────────────
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [callStatus, setCallStatus] = useState('Audio Call');
const [isMuted, setIsMuted] = useState(false);
const [isSpeaker, setIsSpeaker] = useState(false);
const [userVisible, setUserVisible] = useState(true);
const [callingText, setCallingText] = useState('Calling');
// ── Animated values ───────────────────────────────────────────────────────
// One { scale, opacity } pair per pulse ring
const pulseAnims = useRef(
RING_ALPHAS.map(alpha => ({
scale: new Animated.Value(1),
opacity: new Animated.Value(alpha),
}))
).current;
const userContentOpacity = useRef(new Animated.Value(1)).current;
const callingLabelOpacity = useRef(new Animated.Value(0)).current;
// ── Internal refs ─────────────────────────────────────────────────────────
const pulseLoopsRef = useRef([]);
const pulseTimeoutsRef = useRef([]);
const dotTimerRef = useRef(null);
const dotStepRef = useRef(0);
const selfUserRef = useRef({ firstName: '', lastName: '', status: 'Audio Call' });
// ── Pulse animation ───────────────────────────────────────────────────────
const cancelPulse = () => {
pulseLoopsRef.current.forEach(loop => loop.stop());
pulseLoopsRef.current = [];
pulseTimeoutsRef.current.forEach(t => clearTimeout(t));
pulseTimeoutsRef.current = [];
pulseAnims.forEach((anim, i) => {
anim.scale.setValue(1);
anim.opacity.setValue(RING_ALPHAS[i]);
});
};
const startPulse = useCallback(() => {
cancelPulse();
pulseAnims.forEach((anim, i) => {
const loop = Animated.loop(
Animated.sequence([
Animated.parallel([
Animated.timing(anim.scale, {
toValue: 1.18,
duration: 1350,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(anim.opacity, {
toValue: 0.04,
duration: 1350,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]),
Animated.parallel([
Animated.timing(anim.scale, {
toValue: 1.0,
duration: 1350,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(anim.opacity, {
toValue: RING_ALPHAS[i],
duration: 1350,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]),
])
);
// Stagger each ring's start so they ripple outward
const t = setTimeout(() => loop.start(), RING_DELAYS[i]);
pulseTimeoutsRef.current.push(t);
pulseLoopsRef.current.push(loop);
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const stopPulse = useCallback(cancelPulse, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Calling dots animation ────────────────────────────────────────────────
const startCallingDots = () => {
dotStepRef.current = 0;
setCallingText('Calling');
if (dotTimerRef.current) clearInterval(dotTimerRef.current);
dotTimerRef.current = setInterval(() => {
dotStepRef.current = (dotStepRef.current + 1) % 4;
setCallingText('Calling' + '.'.repeat(dotStepRef.current));
}, 500);
};
const stopCallingDots = () => {
if (dotTimerRef.current) {
clearInterval(dotTimerRef.current);
dotTimerRef.current = null;
}
dotStepRef.current = 0;
};
// ── Cleanup on unmount ────────────────────────────────────────────────────
useEffect(() => {
return () => {
cancelPulse();
stopCallingDots();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// ── Imperative API (matches iOS / Android method signatures) ─────────────
useImperativeHandle(ref, () => ({
configure(fn, ln, status = 'Audio Call') {
selfUserRef.current = { firstName: fn || '', lastName: ln || '', status };
setFirstName(fn);
setLastName(ln);
setCallStatus(status);
},
updateActiveTalker(activeTalker, fallbackUser) {
const activeName = getNameParts(activeTalker);
const fallbackName = getNameParts(fallbackUser);
const hasActiveTalker = !!(activeName.firstName || activeName.lastName);
if (hasActiveTalker) {
setFirstName(activeName.firstName);
setLastName(activeName.lastName);
setCallStatus(activeTalker?.clientId === fallbackUser?.clientId ? 'Audio Call' : 'Speaking');
startPulse();
return;
}
setFirstName(fallbackName.firstName || selfUserRef.current.firstName);
setLastName(fallbackName.lastName || selfUserRef.current.lastName);
setCallStatus(selfUserRef.current.status || 'Audio Call');
stopPulse();
},
setSpeaking(speaking) {
speaking ? startPulse() : stopPulse();
},
setUserVisible(visible) {
setUserVisible(visible);
if (visible) {
stopCallingDots();
Animated.parallel([
Animated.timing(userContentOpacity, { toValue: 1, duration: 250, useNativeDriver: true }),
Animated.timing(callingLabelOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
]).start();
} else {
startCallingDots();
Animated.parallel([
Animated.timing(userContentOpacity, { toValue: 0, duration: 250, useNativeDriver: true }),
Animated.timing(callingLabelOpacity, { toValue: 1, duration: 250, useNativeDriver: true }),
]).start();
}
},
}), [startPulse, stopPulse, userContentOpacity, callingLabelOpacity]);
// ── Button handlers ───────────────────────────────────────────────────────
const handleMute = () =>
setIsMuted(prev => { const next = !prev; onMuteToggle?.(next); return next; });
const handleSpeaker = () =>
setIsSpeaker(prev => { const next = !prev; onSpeakerToggle?.(next); return next; });
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
const handleRootLayout = event => {
const { width: nextWidth, height: nextHeight } = event.nativeEvent.layout;
if (
Math.abs(nextWidth - layoutSize.width) > 1 ||
Math.abs(nextHeight - layoutSize.height) > 1
) {
setLayoutSize({ width: nextWidth || width, height: nextHeight || usableHeight });
}
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<LinearGradient
colors={[resolvedAudioConfig.gradientLayer1, resolvedAudioConfig.gradientLayer2]}
onLayout={handleRootLayout}
style={[
styles.root,
{
minHeight: layoutSize.height || usableHeight,
paddingTop: responsiveLayout.topPadding,
paddingBottom: responsiveLayout.bottomPadding,
},
]}
>
{/* ── Calling label — shown when setUserVisible(false) ── */}
<Animated.View
style={[styles.callingContainer, { opacity: callingLabelOpacity }]}
pointerEvents="none"
>
<Text
style={[
styles.callingText,
{
color: resolvedAudioConfig.nameTextColor,
fontSize: responsiveLayout.nameFontSize,
},
]}
>
{callingText}
</Text>
</Animated.View>
<View style={styles.topInfo}>
{firstName ? (
<Text
style={[
styles.nameText,
{
color: resolvedAudioConfig.nameTextColor,
fontSize: responsiveLayout.nameFontSize,
},
]}
numberOfLines={1}
adjustsFontSizeToFit
>
{firstName}
</Text>
) : null}
{callStatus ? (
<Text
style={[
styles.statusText,
{
color: resolvedAudioConfig.statusTextColor,
fontSize: responsiveLayout.statusFontSize,
},
]}
numberOfLines={1}
>
{callStatus}
</Text>
) : null}
</View>
<Animated.View
style={[styles.userContent, { opacity: userContentOpacity }]}
pointerEvents={userVisible ? 'box-none' : 'none'}
>
<View
style={[
styles.avatarContainer,
{
width: responsiveLayout.containerSize,
height: responsiveLayout.containerSize,
marginTop: responsiveLayout.avatarMarginTop,
},
]}
>
{[...responsiveLayout.ringSizes].reverse().map((ringSize, reverseIdx) => {
const i = responsiveLayout.ringSizes.length - 1 - reverseIdx;
const offset = (responsiveLayout.containerSize - ringSize) / 2;
return (
<Animated.View
key={i}
style={[
styles.ring,
{
width: ringSize,
height: ringSize,
borderRadius: ringSize / 2,
top: offset,
left: offset,
backgroundColor: resolvedAudioConfig.ringBackgroundColor,
borderColor: resolvedAudioConfig.ringBorderColor,
opacity: pulseAnims[i].opacity,
transform: [{ scale: pulseAnims[i].scale }],
},
]}
/>
);
})}
<View
style={[
styles.avatarCircle,
{
width: responsiveLayout.avatarSize,
height: responsiveLayout.avatarSize,
borderRadius: responsiveLayout.avatarSize / 2,
backgroundColor: resolvedAudioConfig.sortNameBGColor,
top: (responsiveLayout.containerSize - responsiveLayout.avatarSize) / 2,
left: (responsiveLayout.containerSize - responsiveLayout.avatarSize) / 2,
},
]}
>
<Text
style={[
styles.initialsText,
{
color: resolvedAudioConfig.sortNameTextColor,
fontSize: responsiveLayout.initialsFontSize,
},
]}
>
{initials}
</Text>
</View>
</View>
</Animated.View>
<View
style={[
styles.topControlCard,
{
width: responsiveLayout.cardWidth,
maxWidth: '94%',
paddingVertical: responsiveLayout.cardPaddingVertical,
paddingHorizontal: responsiveLayout.cardPaddingHorizontal,
borderRadius: responsiveLayout.cardRadius,
backgroundColor: resolvedAudioConfig.controlCardColor,
},
]}
>
<View style={styles.controlItem}>
<TouchableOpacity
style={[
styles.circleBtn,
{
width: responsiveLayout.buttonSize,
height: responsiveLayout.buttonSize,
borderRadius: responsiveLayout.buttonSize / 2,
backgroundColor: isMuted
? resolvedAudioConfig.activeControlButtonColor
: resolvedAudioConfig.controlButtonColor,
},
isMuted && { borderWidth: 1, borderColor: ACTIVE_COLOR },
]}
onPress={handleMute}
activeOpacity={0.8}
>
<MicIcon color="#FFFFFF" muted={isMuted} size={responsiveLayout.iconFontSize} />
</TouchableOpacity>
<Text
style={[
styles.cardLabel,
{
color: resolvedAudioConfig.nameTextColor,
fontSize: responsiveLayout.labelFontSize,
},
]}
>
{isMuted ? 'Unmute' : 'Mute'}
</Text>
</View>
<View style={styles.controlItem}>
<TouchableOpacity
style={[
styles.circleBtn,
styles.endCallBtn,
{
width: responsiveLayout.buttonSize,
height: responsiveLayout.buttonSize,
borderRadius: responsiveLayout.buttonSize / 2,
},
]}
onPress={onDisconnect}
activeOpacity={0.8}
>
<PhoneDownIcon size={responsiveLayout.iconFontSize} />
</TouchableOpacity>
<Text
style={[
styles.cardLabel,
{
color: resolvedAudioConfig.nameTextColor,
fontSize: responsiveLayout.labelFontSize,
},
]}
>
End Call
</Text>
</View>
<View style={styles.controlItem}>
<TouchableOpacity
style={[
styles.circleBtn,
{
width: responsiveLayout.buttonSize,
height: responsiveLayout.buttonSize,
borderRadius: responsiveLayout.buttonSize / 2,
backgroundColor: isSpeaker
? resolvedAudioConfig.activeControlButtonColor
: resolvedAudioConfig.controlButtonColor,
},
isSpeaker && { borderWidth: 1, borderColor: ACTIVE_COLOR },
]}
onPress={handleSpeaker}
activeOpacity={0.8}
>
<SpeakerIcon color="#FFFFFF" active={isSpeaker} size={responsiveLayout.iconFontSize} />
</TouchableOpacity>
<Text
style={[
styles.cardLabel,
{
color: resolvedAudioConfig.nameTextColor,
fontSize: responsiveLayout.labelFontSize,
},
]}
>
{isSpeaker ? 'Earpiece' : 'Speaker'}
</Text>
</View>
</View>
</LinearGradient>
);
}
);
EnxAudioOnlyView.displayName = 'EnxAudioOnlyView';
export default EnxAudioOnlyView;
// ─── Styles ───────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
root: {
flex: 1,
flexGrow: 1,
alignSelf: 'stretch',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 24 : 12,
paddingBottom: Platform.OS === 'ios' ? 24 : 14,
backgroundColor: '#121213',
},
// Calling label — absolute, centered in the space above content
callingContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
callingText: {
fontSize: 32,
fontWeight: '600',
color: '#FFFFFF',
textAlign: 'center',
},
// User content — centered avatar + labels
userContent: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
width: '100%',
},
topInfo: {
alignItems: 'center',
paddingHorizontal: 24,
marginTop: 4,
width: '100%',
},
topControlCard: {
width: '92%',
marginBottom: 0,
paddingVertical: 12,
paddingHorizontal: 14,
borderRadius: 24,
backgroundColor: 'rgba(0,0,0,0.8)',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 20,
elevation: 6,
},
cardLabel: {
marginTop: 8,
color: '#FFFFFF',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
nameText: {
fontSize: 30,
fontWeight: '600',
color: '#FFFFFF',
textAlign: 'center',
},
statusText: {
fontSize: 16,
color: '#D1D5DB',
marginTop: 10,
textAlign: 'center',
},
avatarContainer: {
width: BASE_AVATAR_SIZE + 160,
height: BASE_AVATAR_SIZE + 160,
marginTop: 10,
},
ring: {
position: 'absolute',
backgroundColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
},
avatarCircle: {
position: 'absolute',
width: BASE_AVATAR_SIZE,
height: BASE_AVATAR_SIZE,
borderRadius: BASE_AVATAR_SIZE / 2,
backgroundColor: '#009688',
top: 80,
left: 80,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.12,
shadowRadius: 20,
elevation: 4,
},
initialsText: {
fontSize: 44,
fontWeight: '500',
color: 'white',
},
// Controls bar — bottom button row
controlsBar: {
position: 'absolute',
bottom: Platform.OS === 'ios' ? 40 : 28,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-evenly',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
},
iconContainer: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
iconText: {
fontSize: 20,
lineHeight: 24,
},
imageIcon: {
tintColor: '#FFFFFF',
},
controlItem: {
alignItems: 'center',
},
circleBtn: {
width: BASE_BUTTON_SIZE,
height: BASE_BUTTON_SIZE,
borderRadius: BASE_BUTTON_SIZE / 2,
backgroundColor: '#1F1F1F',
alignItems: 'center',
justifyContent: 'center',
},
circleBtnActive: {
backgroundColor: '#063B34',
borderWidth: 1,
borderColor: ACTIVE_COLOR,
},
endCallBtn: {
backgroundColor: '#EE3A3A',
},
btnLabel: {
color: 'rgba(238, 235, 235, 0.97)',
fontSize: 12,
marginTop: 8,
textAlign: 'center',
},
});