feat: Add floating bubble during voice calls

- 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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-24 20:39:27 -08:00
parent 513d9c3c7d
commit aec300bd98
4 changed files with 487 additions and 31 deletions

View File

@ -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
)}
</View>
{/* Call controls */}
<View style={voiceStyles.callControls}>
{/* Minimize button */}
<TouchableOpacity style={voiceStyles.minimizeButton} onPress={onMinimize}>
<Ionicons name="chevron-down" size={28} color={AppColors.white} />
</TouchableOpacity>
{/* Hang up button */}
<TouchableOpacity style={voiceStyles.hangUpButton} onPress={onHangUp}>
<Ionicons name="call" size={32} color={AppColors.white} style={{ transform: [{ rotate: '135deg' }] }} />
</TouchableOpacity>
{/* Placeholder for symmetry */}
<View style={voiceStyles.controlPlaceholder} />
</View>
</View>
);
}
@ -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<Message[]>([
@ -364,10 +409,7 @@ export default function ChatScreen() {
},
]);
// Voice call state
const [isVoiceCallActive, setIsVoiceCallActive] = useState(false);
const [voiceToken, setVoiceToken] = useState<string | undefined>(undefined);
const [voiceWsUrl, setVoiceWsUrl] = useState<string | undefined>(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() {
<View style={styles.inputContainer}>
{/* Voice Call Button */}
<TouchableOpacity
style={[styles.voiceButton, isConnectingVoice && styles.voiceButtonConnecting]}
onPress={startVoiceCall}
style={[
styles.voiceButton,
(isConnectingVoice || isCallActive) && styles.voiceButtonConnecting,
]}
onPress={isCallActive ? maximizeCall : startVoiceCall}
disabled={isConnectingVoice}
>
{isConnectingVoice ? (
<ActivityIndicator size="small" color={AppColors.primary} />
) : isCallActive ? (
<Ionicons name="call" size={20} color={AppColors.success} />
) : (
<Ionicons name="call" size={20} color={AppColors.primary} />
)}
@ -817,16 +865,16 @@ export default function ChatScreen() {
{/* Voice Call Modal */}
<Modal
visible={isVoiceCallActive}
visible={isCallActive && !callState.isMinimized}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={endVoiceCall}
onRequestClose={minimizeCall}
>
<SafeAreaView style={{ flex: 1, backgroundColor: 'black' }} edges={['top', 'bottom']}>
{voiceToken && voiceWsUrl ? (
{callState.token && callState.wsUrl ? (
<LiveKitRoom
serverUrl={voiceWsUrl}
token={voiceToken}
serverUrl={callState.wsUrl}
token={callState.token}
connect={true}
audio={true}
video={false}
@ -840,7 +888,9 @@ export default function ChatScreen() {
>
<VoiceCallContent
onHangUp={endVoiceCall}
onMinimize={minimizeCall}
onTranscript={handleVoiceTranscript}
onDurationUpdate={updateDuration}
beneficiaryName={currentBeneficiary?.name}
/>
</LiveKitRoom>
@ -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,

View File

@ -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() {
<Stack.Screen name="terms" options={{ presentation: 'modal' }} />
<Stack.Screen name="privacy" options={{ presentation: 'modal' }} />
</Stack>
<FloatingCallBubble />
<StatusBar style="auto" />
</ThemeProvider>
);
@ -64,7 +67,9 @@ export default function RootLayout() {
<AuthProvider>
<BeneficiaryProvider>
<VoiceTranscriptProvider>
<VoiceCallProvider>
<RootLayoutNav />
</VoiceCallProvider>
</VoiceTranscriptProvider>
</BeneficiaryProvider>
</AuthProvider>

View File

@ -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 (
<Animated.View
style={[
styles.container,
{
transform: [
{ translateX: pan.x },
{ translateY: pan.y },
{ scale },
],
},
]}
{...panResponder.panHandlers}
>
{/* Pulse ring */}
<Animated.View
style={[
styles.pulseRing,
{
transform: [{ scale: pulseAnim }],
},
]}
/>
{/* Main bubble */}
<TouchableOpacity
style={styles.bubble}
onPress={maximizeCall}
activeOpacity={0.9}
>
<View style={styles.avatarContainer}>
<Text style={styles.avatarText}>J</Text>
</View>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>{formatDuration(displayDuration)}</Text>
</View>
</TouchableOpacity>
{/* End call button */}
<TouchableOpacity style={styles.endButton} onPress={endCall}>
<Ionicons name="call" size={14} color={AppColors.white} style={{ transform: [{ rotate: '135deg' }] }} />
</TouchableOpacity>
</Animated.View>
);
}
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,
},
});

View File

@ -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<VoiceCallContextValue | undefined>(undefined);
export function VoiceCallProvider({ children }: { children: ReactNode }) {
const [callState, setCallState] = useState<VoiceCallState>(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 (
<VoiceCallContext.Provider
value={{
callState,
startCall,
endCall,
minimizeCall,
maximizeCall,
updateDuration,
isCallActive: callState.isActive,
}}
>
{children}
</VoiceCallContext.Provider>
);
}
export function useVoiceCall() {
const context = useContext(VoiceCallContext);
if (!context) {
throw new Error('useVoiceCall must be used within VoiceCallProvider');
}
return context;
}