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:
parent
513d9c3c7d
commit
aec300bd98
@ -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>
|
||||
|
||||
{/* Hang up button */}
|
||||
<TouchableOpacity style={voiceStyles.hangUpButton} onPress={onHangUp}>
|
||||
<Ionicons name="call" size={32} color={AppColors.white} style={{ transform: [{ rotate: '135deg' }] }} />
|
||||
</TouchableOpacity>
|
||||
{/* 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,
|
||||
|
||||
@ -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>
|
||||
<RootLayoutNav />
|
||||
<VoiceCallProvider>
|
||||
<RootLayoutNav />
|
||||
</VoiceCallProvider>
|
||||
</VoiceTranscriptProvider>
|
||||
</BeneficiaryProvider>
|
||||
</AuthProvider>
|
||||
|
||||
263
components/FloatingCallBubble.tsx
Normal file
263
components/FloatingCallBubble.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
137
contexts/VoiceCallContext.tsx
Normal file
137
contexts/VoiceCallContext.tsx
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user