/** * Floating Call Bubble Component * * Shows a floating bubble during active voice calls. * Can be dragged around the screen. * Tapping it returns to the full voice call screen. */ import React, { useEffect, useRef, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Animated, PanResponder, Dimensions, Platform, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { AppColors, FontSizes, Spacing } from '@/constants/theme'; import { useVoiceCall } from '@/contexts/VoiceCallContext'; const BUBBLE_SIZE = 70; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); export function FloatingCallBubble() { const { callState, maximizeCall, endCall } = useVoiceCall(); const insets = useSafeAreaInsets(); // Animation values const pan = useRef(new Animated.ValueXY({ x: SCREEN_WIDTH - BUBBLE_SIZE - 16, y: insets.top + 100, })).current; const scale = useRef(new Animated.Value(0)).current; const pulseAnim = useRef(new Animated.Value(1)).current; // Local duration state (updates from context) const [displayDuration, setDisplayDuration] = useState(callState.callDuration); // Update display duration when context changes useEffect(() => { setDisplayDuration(callState.callDuration); }, [callState.callDuration]); // Duration timer (local increment for smooth display) useEffect(() => { if (callState.isActive && callState.isMinimized) { const interval = setInterval(() => { setDisplayDuration(prev => prev + 1); }, 1000); return () => clearInterval(interval); } }, [callState.isActive, callState.isMinimized]); // Show/hide animation useEffect(() => { if (callState.isActive && callState.isMinimized) { // Show bubble Animated.spring(scale, { toValue: 1, friction: 5, tension: 40, useNativeDriver: true, }).start(); } else { // Hide bubble Animated.timing(scale, { toValue: 0, duration: 200, useNativeDriver: true, }).start(); } }, [callState.isActive, callState.isMinimized, scale]); // Pulse animation useEffect(() => { if (callState.isActive && callState.isMinimized) { const pulse = Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { toValue: 1.1, duration: 1000, useNativeDriver: true, }), Animated.timing(pulseAnim, { toValue: 1, duration: 1000, useNativeDriver: true, }), ]) ); pulse.start(); return () => pulse.stop(); } }, [callState.isActive, callState.isMinimized, pulseAnim]); // Pan responder for dragging const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderGrant: () => { pan.extractOffset(); }, onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false, }), onPanResponderRelease: (_, gestureState) => { pan.flattenOffset(); // Snap to edge const currentX = (pan.x as any)._value; const currentY = (pan.y as any)._value; const snapToLeft = currentX < SCREEN_WIDTH / 2; const targetX = snapToLeft ? 16 : SCREEN_WIDTH - BUBBLE_SIZE - 16; // Clamp Y within screen bounds const minY = insets.top + 16; const maxY = SCREEN_HEIGHT - BUBBLE_SIZE - insets.bottom - 100; const targetY = Math.max(minY, Math.min(currentY, maxY)); Animated.spring(pan, { toValue: { x: targetX, y: targetY }, friction: 7, useNativeDriver: false, }).start(); }, }) ).current; // Format duration as mm:ss const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; // Don't render if not showing if (!callState.isActive || !callState.isMinimized) { return null; } return ( {/* Pulse ring */} {/* Main bubble */} J {formatDuration(displayDuration)} {/* End call button */} ); } const styles = StyleSheet.create({ container: { position: 'absolute', zIndex: 9999, width: BUBBLE_SIZE, height: BUBBLE_SIZE, }, pulseRing: { position: 'absolute', width: BUBBLE_SIZE, height: BUBBLE_SIZE, borderRadius: BUBBLE_SIZE / 2, backgroundColor: 'rgba(90, 200, 168, 0.3)', }, bubble: { width: BUBBLE_SIZE, height: BUBBLE_SIZE, borderRadius: BUBBLE_SIZE / 2, backgroundColor: AppColors.success, justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 10, }, avatarContainer: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255, 255, 255, 0.2)', justifyContent: 'center', alignItems: 'center', }, avatarText: { fontSize: FontSizes.xl, fontWeight: '600', color: AppColors.white, }, durationBadge: { position: 'absolute', bottom: -4, backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 8, }, durationText: { fontSize: 10, fontWeight: '600', color: AppColors.white, fontVariant: ['tabular-nums'], }, endButton: { position: 'absolute', top: -4, right: -4, width: 24, height: 24, borderRadius: 12, backgroundColor: AppColors.error, justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.25, shadowRadius: 4, elevation: 5, }, });