From aec300bd981064f053d0baf41e38fbadbaa9935e Mon Sep 17 00:00:00 2001 From: Sergei Date: Sat, 24 Jan 2026 20:39:27 -0800 Subject: [PATCH] feat: Add floating bubble during voice calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create VoiceCallContext for global voice call state management - Add FloatingCallBubble component with drag support - Add minimize button to voice call screen - Show bubble when call is minimized, tap to return to call - Button shows active call state with green color 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/(tabs)/chat.tsx | 111 +++++++++---- app/_layout.tsx | 7 +- components/FloatingCallBubble.tsx | 263 ++++++++++++++++++++++++++++++ contexts/VoiceCallContext.tsx | 137 ++++++++++++++++ 4 files changed, 487 insertions(+), 31 deletions(-) create mode 100644 components/FloatingCallBubble.tsx create mode 100644 contexts/VoiceCallContext.tsx diff --git a/app/(tabs)/chat.tsx b/app/(tabs)/chat.tsx index 68c3a0d..5806d9e 100644 --- a/app/(tabs)/chat.tsx +++ b/app/(tabs)/chat.tsx @@ -26,6 +26,7 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; import { api } from '@/services/api'; import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext'; +import { useVoiceCall } from '@/contexts/VoiceCallContext'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import type { Message, Beneficiary } from '@/types'; @@ -132,11 +133,13 @@ function normalizeQuestion(userMessage: string): string { interface VoiceCallOverlayProps { onHangUp: () => void; + onMinimize: () => void; onTranscript: (role: 'user' | 'assistant', text: string) => void; + onDurationUpdate: (seconds: number) => void; beneficiaryName?: string; } -function VoiceCallContent({ onHangUp, onTranscript, beneficiaryName }: VoiceCallOverlayProps) { +function VoiceCallContent({ onHangUp, onMinimize, onTranscript, onDurationUpdate, beneficiaryName }: VoiceCallOverlayProps) { const room = useRoomContext(); const connectionState = useConnectionState(); const { state: agentState, audioTrack } = useVoiceAssistant(); @@ -182,11 +185,15 @@ function VoiceCallContent({ onHangUp, onTranscript, beneficiaryName }: VoiceCall useEffect(() => { if (connectionState === ConnectionState.Connected) { const interval = setInterval(() => { - setCallDuration(prev => prev + 1); + setCallDuration(prev => { + const newDuration = prev + 1; + onDurationUpdate(newDuration); + return newDuration; + }); }, 1000); return () => clearInterval(interval); } - }, [connectionState]); + }, [connectionState, onDurationUpdate]); // Keep screen awake during call useEffect(() => { @@ -259,10 +266,21 @@ function VoiceCallContent({ onHangUp, onTranscript, beneficiaryName }: VoiceCall )} - {/* Hang up button */} - - - + {/* Call controls */} + + {/* Minimize button */} + + + + + {/* Hang up button */} + + + + + {/* Placeholder for symmetry */} + + ); } @@ -337,6 +355,21 @@ const voiceStyles = StyleSheet.create({ height: 60, width: 200, }, + callControls: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.xl, + marginBottom: Spacing.xl, + }, + minimizeButton: { + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, hangUpButton: { width: 72, height: 72, @@ -344,7 +377,10 @@ const voiceStyles = StyleSheet.create({ backgroundColor: AppColors.error, justifyContent: 'center', alignItems: 'center', - marginBottom: Spacing.xl, + }, + controlPlaceholder: { + width: 56, + height: 56, }, }); @@ -353,6 +389,15 @@ export default function ChatScreen() { const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary(); const { getTranscriptAsMessages, hasNewTranscript, markTranscriptAsShown, addTranscriptEntry, clearTranscript } = useVoiceTranscript(); const { user } = useAuth(); + const { + callState, + startCall, + endCall: endVoiceCallContext, + minimizeCall, + maximizeCall, + updateDuration, + isCallActive, + } = useVoiceCall(); // Chat state const [messages, setMessages] = useState([ @@ -364,10 +409,7 @@ export default function ChatScreen() { }, ]); - // Voice call state - const [isVoiceCallActive, setIsVoiceCallActive] = useState(false); - const [voiceToken, setVoiceToken] = useState(undefined); - const [voiceWsUrl, setVoiceWsUrl] = useState(undefined); + // Voice call state (local connecting state only) const [isConnectingVoice, setIsConnectingVoice] = useState(false); // Add voice call transcript to messages when returning from call @@ -463,7 +505,7 @@ export default function ChatScreen() { // Start voice call const startVoiceCall = useCallback(async () => { - if (isConnectingVoice || isVoiceCallActive) return; + if (isConnectingVoice || isCallActive) return; setIsConnectingVoice(true); console.log('[Chat] Starting voice call...'); @@ -492,11 +534,14 @@ export default function ChatScreen() { console.log('[Chat] Got voice token, connecting to room:', tokenResponse.data.roomName); - // Clear previous transcript and start call + // Clear previous transcript and start call via context clearTranscript(); - setVoiceToken(tokenResponse.data.token); - setVoiceWsUrl(tokenResponse.data.wsUrl); - setIsVoiceCallActive(true); + startCall({ + token: tokenResponse.data.token, + wsUrl: tokenResponse.data.wsUrl, + beneficiaryName: currentBeneficiary?.name, + beneficiaryId: currentBeneficiary?.id?.toString(), + }); } catch (error) { console.error('[Chat] Voice call error:', error); Alert.alert( @@ -506,15 +551,13 @@ export default function ChatScreen() { } finally { setIsConnectingVoice(false); } - }, [isConnectingVoice, isVoiceCallActive, currentBeneficiary, beneficiaries, user, clearTranscript]); + }, [isConnectingVoice, isCallActive, currentBeneficiary, beneficiaries, user, clearTranscript, startCall]); // End voice call const endVoiceCall = useCallback(() => { console.log('[Chat] Ending voice call...'); - setIsVoiceCallActive(false); - setVoiceToken(undefined); - setVoiceWsUrl(undefined); - }, []); + endVoiceCallContext(); + }, [endVoiceCallContext]); // Handle voice transcript entries const handleVoiceTranscript = useCallback((role: 'user' | 'assistant', text: string) => { @@ -780,12 +823,17 @@ export default function ChatScreen() { {/* Voice Call Button */} {isConnectingVoice ? ( + ) : isCallActive ? ( + ) : ( )} @@ -817,16 +865,16 @@ export default function ChatScreen() { {/* Voice Call Modal */} - {voiceToken && voiceWsUrl ? ( + {callState.token && callState.wsUrl ? ( @@ -1006,7 +1056,8 @@ const styles = StyleSheet.create({ borderColor: AppColors.primary, }, voiceButtonConnecting: { - opacity: 0.6, + borderColor: AppColors.success, + backgroundColor: 'rgba(90, 200, 168, 0.1)', }, sendButton: { width: 44, diff --git a/app/_layout.tsx b/app/_layout.tsx index 2d4418f..257e363 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,7 +13,9 @@ import { useColorScheme } from '@/hooks/use-color-scheme'; import { AuthProvider, useAuth } from '@/contexts/AuthContext'; import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext'; import { VoiceTranscriptProvider } from '@/contexts/VoiceTranscriptContext'; +import { VoiceCallProvider } from '@/contexts/VoiceCallContext'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { FloatingCallBubble } from '@/components/FloatingCallBubble'; // Prevent auto-hiding splash screen SplashScreen.preventAutoHideAsync(); @@ -53,6 +55,7 @@ function RootLayoutNav() { + ); @@ -64,7 +67,9 @@ export default function RootLayout() { - + + + diff --git a/components/FloatingCallBubble.tsx b/components/FloatingCallBubble.tsx new file mode 100644 index 0000000..b80a39b --- /dev/null +++ b/components/FloatingCallBubble.tsx @@ -0,0 +1,263 @@ +/** + * 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, + }, +}); diff --git a/contexts/VoiceCallContext.tsx b/contexts/VoiceCallContext.tsx new file mode 100644 index 0000000..af0b2b8 --- /dev/null +++ b/contexts/VoiceCallContext.tsx @@ -0,0 +1,137 @@ +/** + * Voice Call Context + * + * Global state for voice calls that persists across screens. + * Enables floating bubble when call is active and user navigates away. + */ + +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; + +interface VoiceCallState { + // Whether a voice call is currently active + isActive: boolean; + // Whether the call UI is minimized (showing bubble instead of full screen) + isMinimized: boolean; + // LiveKit connection details + token: string | undefined; + wsUrl: string | undefined; + // Call metadata + beneficiaryName: string | undefined; + beneficiaryId: string | undefined; + // Call duration in seconds + callDuration: number; +} + +interface VoiceCallContextValue { + // Current call state + callState: VoiceCallState; + + // Start a new voice call + startCall: (params: { + token: string; + wsUrl: string; + beneficiaryName?: string; + beneficiaryId?: string; + }) => void; + + // End the current call + endCall: () => void; + + // Minimize call (show floating bubble) + minimizeCall: () => void; + + // Maximize call (show full screen) + maximizeCall: () => void; + + // Update call duration + updateDuration: (seconds: number) => void; + + // Check if call is active + isCallActive: boolean; +} + +const initialState: VoiceCallState = { + isActive: false, + isMinimized: false, + token: undefined, + wsUrl: undefined, + beneficiaryName: undefined, + beneficiaryId: undefined, + callDuration: 0, +}; + +const VoiceCallContext = createContext(undefined); + +export function VoiceCallProvider({ children }: { children: ReactNode }) { + const [callState, setCallState] = useState(initialState); + + const startCall = useCallback((params: { + token: string; + wsUrl: string; + beneficiaryName?: string; + beneficiaryId?: string; + }) => { + console.log('[VoiceCallContext] Starting call'); + setCallState({ + isActive: true, + isMinimized: false, + token: params.token, + wsUrl: params.wsUrl, + beneficiaryName: params.beneficiaryName, + beneficiaryId: params.beneficiaryId, + callDuration: 0, + }); + }, []); + + const endCall = useCallback(() => { + console.log('[VoiceCallContext] Ending call'); + setCallState(initialState); + }, []); + + const minimizeCall = useCallback(() => { + console.log('[VoiceCallContext] Minimizing call'); + setCallState(prev => ({ + ...prev, + isMinimized: true, + })); + }, []); + + const maximizeCall = useCallback(() => { + console.log('[VoiceCallContext] Maximizing call'); + setCallState(prev => ({ + ...prev, + isMinimized: false, + })); + }, []); + + const updateDuration = useCallback((seconds: number) => { + setCallState(prev => ({ + ...prev, + callDuration: seconds, + })); + }, []); + + return ( + + {children} + + ); +} + +export function useVoiceCall() { + const context = useContext(VoiceCallContext); + if (!context) { + throw new Error('useVoiceCall must be used within VoiceCallProvider'); + } + return context; +}