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 { api } from '@/services/api';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
|
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
|
||||||
|
import { useVoiceCall } from '@/contexts/VoiceCallContext';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||||
import type { Message, Beneficiary } from '@/types';
|
import type { Message, Beneficiary } from '@/types';
|
||||||
|
|
||||||
@ -132,11 +133,13 @@ function normalizeQuestion(userMessage: string): string {
|
|||||||
|
|
||||||
interface VoiceCallOverlayProps {
|
interface VoiceCallOverlayProps {
|
||||||
onHangUp: () => void;
|
onHangUp: () => void;
|
||||||
|
onMinimize: () => void;
|
||||||
onTranscript: (role: 'user' | 'assistant', text: string) => void;
|
onTranscript: (role: 'user' | 'assistant', text: string) => void;
|
||||||
|
onDurationUpdate: (seconds: number) => void;
|
||||||
beneficiaryName?: string;
|
beneficiaryName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function VoiceCallContent({ onHangUp, onTranscript, beneficiaryName }: VoiceCallOverlayProps) {
|
function VoiceCallContent({ onHangUp, onMinimize, onTranscript, onDurationUpdate, beneficiaryName }: VoiceCallOverlayProps) {
|
||||||
const room = useRoomContext();
|
const room = useRoomContext();
|
||||||
const connectionState = useConnectionState();
|
const connectionState = useConnectionState();
|
||||||
const { state: agentState, audioTrack } = useVoiceAssistant();
|
const { state: agentState, audioTrack } = useVoiceAssistant();
|
||||||
@ -182,11 +185,15 @@ function VoiceCallContent({ onHangUp, onTranscript, beneficiaryName }: VoiceCall
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionState === ConnectionState.Connected) {
|
if (connectionState === ConnectionState.Connected) {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setCallDuration(prev => prev + 1);
|
setCallDuration(prev => {
|
||||||
|
const newDuration = prev + 1;
|
||||||
|
onDurationUpdate(newDuration);
|
||||||
|
return newDuration;
|
||||||
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [connectionState]);
|
}, [connectionState, onDurationUpdate]);
|
||||||
|
|
||||||
// Keep screen awake during call
|
// Keep screen awake during call
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -259,10 +266,21 @@ function VoiceCallContent({ onHangUp, onTranscript, beneficiaryName }: VoiceCall
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Hang up button */}
|
{/* Call controls */}
|
||||||
<TouchableOpacity style={voiceStyles.hangUpButton} onPress={onHangUp}>
|
<View style={voiceStyles.callControls}>
|
||||||
<Ionicons name="call" size={32} color={AppColors.white} style={{ transform: [{ rotate: '135deg' }] }} />
|
{/* Minimize button */}
|
||||||
</TouchableOpacity>
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -337,6 +355,21 @@ const voiceStyles = StyleSheet.create({
|
|||||||
height: 60,
|
height: 60,
|
||||||
width: 200,
|
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: {
|
hangUpButton: {
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
@ -344,7 +377,10 @@ const voiceStyles = StyleSheet.create({
|
|||||||
backgroundColor: AppColors.error,
|
backgroundColor: AppColors.error,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: Spacing.xl,
|
},
|
||||||
|
controlPlaceholder: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -353,6 +389,15 @@ export default function ChatScreen() {
|
|||||||
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
|
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
|
||||||
const { getTranscriptAsMessages, hasNewTranscript, markTranscriptAsShown, addTranscriptEntry, clearTranscript } = useVoiceTranscript();
|
const { getTranscriptAsMessages, hasNewTranscript, markTranscriptAsShown, addTranscriptEntry, clearTranscript } = useVoiceTranscript();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const {
|
||||||
|
callState,
|
||||||
|
startCall,
|
||||||
|
endCall: endVoiceCallContext,
|
||||||
|
minimizeCall,
|
||||||
|
maximizeCall,
|
||||||
|
updateDuration,
|
||||||
|
isCallActive,
|
||||||
|
} = useVoiceCall();
|
||||||
|
|
||||||
// Chat state
|
// Chat state
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
@ -364,10 +409,7 @@ export default function ChatScreen() {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Voice call state
|
// Voice call state (local connecting state only)
|
||||||
const [isVoiceCallActive, setIsVoiceCallActive] = useState(false);
|
|
||||||
const [voiceToken, setVoiceToken] = useState<string | undefined>(undefined);
|
|
||||||
const [voiceWsUrl, setVoiceWsUrl] = useState<string | undefined>(undefined);
|
|
||||||
const [isConnectingVoice, setIsConnectingVoice] = useState(false);
|
const [isConnectingVoice, setIsConnectingVoice] = useState(false);
|
||||||
|
|
||||||
// Add voice call transcript to messages when returning from call
|
// Add voice call transcript to messages when returning from call
|
||||||
@ -463,7 +505,7 @@ export default function ChatScreen() {
|
|||||||
|
|
||||||
// Start voice call
|
// Start voice call
|
||||||
const startVoiceCall = useCallback(async () => {
|
const startVoiceCall = useCallback(async () => {
|
||||||
if (isConnectingVoice || isVoiceCallActive) return;
|
if (isConnectingVoice || isCallActive) return;
|
||||||
|
|
||||||
setIsConnectingVoice(true);
|
setIsConnectingVoice(true);
|
||||||
console.log('[Chat] Starting voice call...');
|
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);
|
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();
|
clearTranscript();
|
||||||
setVoiceToken(tokenResponse.data.token);
|
startCall({
|
||||||
setVoiceWsUrl(tokenResponse.data.wsUrl);
|
token: tokenResponse.data.token,
|
||||||
setIsVoiceCallActive(true);
|
wsUrl: tokenResponse.data.wsUrl,
|
||||||
|
beneficiaryName: currentBeneficiary?.name,
|
||||||
|
beneficiaryId: currentBeneficiary?.id?.toString(),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Chat] Voice call error:', error);
|
console.error('[Chat] Voice call error:', error);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@ -506,15 +551,13 @@ export default function ChatScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsConnectingVoice(false);
|
setIsConnectingVoice(false);
|
||||||
}
|
}
|
||||||
}, [isConnectingVoice, isVoiceCallActive, currentBeneficiary, beneficiaries, user, clearTranscript]);
|
}, [isConnectingVoice, isCallActive, currentBeneficiary, beneficiaries, user, clearTranscript, startCall]);
|
||||||
|
|
||||||
// End voice call
|
// End voice call
|
||||||
const endVoiceCall = useCallback(() => {
|
const endVoiceCall = useCallback(() => {
|
||||||
console.log('[Chat] Ending voice call...');
|
console.log('[Chat] Ending voice call...');
|
||||||
setIsVoiceCallActive(false);
|
endVoiceCallContext();
|
||||||
setVoiceToken(undefined);
|
}, [endVoiceCallContext]);
|
||||||
setVoiceWsUrl(undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle voice transcript entries
|
// Handle voice transcript entries
|
||||||
const handleVoiceTranscript = useCallback((role: 'user' | 'assistant', text: string) => {
|
const handleVoiceTranscript = useCallback((role: 'user' | 'assistant', text: string) => {
|
||||||
@ -780,12 +823,17 @@ export default function ChatScreen() {
|
|||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
{/* Voice Call Button */}
|
{/* Voice Call Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.voiceButton, isConnectingVoice && styles.voiceButtonConnecting]}
|
style={[
|
||||||
onPress={startVoiceCall}
|
styles.voiceButton,
|
||||||
|
(isConnectingVoice || isCallActive) && styles.voiceButtonConnecting,
|
||||||
|
]}
|
||||||
|
onPress={isCallActive ? maximizeCall : startVoiceCall}
|
||||||
disabled={isConnectingVoice}
|
disabled={isConnectingVoice}
|
||||||
>
|
>
|
||||||
{isConnectingVoice ? (
|
{isConnectingVoice ? (
|
||||||
<ActivityIndicator size="small" color={AppColors.primary} />
|
<ActivityIndicator size="small" color={AppColors.primary} />
|
||||||
|
) : isCallActive ? (
|
||||||
|
<Ionicons name="call" size={20} color={AppColors.success} />
|
||||||
) : (
|
) : (
|
||||||
<Ionicons name="call" size={20} color={AppColors.primary} />
|
<Ionicons name="call" size={20} color={AppColors.primary} />
|
||||||
)}
|
)}
|
||||||
@ -817,16 +865,16 @@ export default function ChatScreen() {
|
|||||||
|
|
||||||
{/* Voice Call Modal */}
|
{/* Voice Call Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
visible={isVoiceCallActive}
|
visible={isCallActive && !callState.isMinimized}
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
presentationStyle="fullScreen"
|
presentationStyle="fullScreen"
|
||||||
onRequestClose={endVoiceCall}
|
onRequestClose={minimizeCall}
|
||||||
>
|
>
|
||||||
<SafeAreaView style={{ flex: 1, backgroundColor: 'black' }} edges={['top', 'bottom']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: 'black' }} edges={['top', 'bottom']}>
|
||||||
{voiceToken && voiceWsUrl ? (
|
{callState.token && callState.wsUrl ? (
|
||||||
<LiveKitRoom
|
<LiveKitRoom
|
||||||
serverUrl={voiceWsUrl}
|
serverUrl={callState.wsUrl}
|
||||||
token={voiceToken}
|
token={callState.token}
|
||||||
connect={true}
|
connect={true}
|
||||||
audio={true}
|
audio={true}
|
||||||
video={false}
|
video={false}
|
||||||
@ -840,7 +888,9 @@ export default function ChatScreen() {
|
|||||||
>
|
>
|
||||||
<VoiceCallContent
|
<VoiceCallContent
|
||||||
onHangUp={endVoiceCall}
|
onHangUp={endVoiceCall}
|
||||||
|
onMinimize={minimizeCall}
|
||||||
onTranscript={handleVoiceTranscript}
|
onTranscript={handleVoiceTranscript}
|
||||||
|
onDurationUpdate={updateDuration}
|
||||||
beneficiaryName={currentBeneficiary?.name}
|
beneficiaryName={currentBeneficiary?.name}
|
||||||
/>
|
/>
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
@ -1006,7 +1056,8 @@ const styles = StyleSheet.create({
|
|||||||
borderColor: AppColors.primary,
|
borderColor: AppColors.primary,
|
||||||
},
|
},
|
||||||
voiceButtonConnecting: {
|
voiceButtonConnecting: {
|
||||||
opacity: 0.6,
|
borderColor: AppColors.success,
|
||||||
|
backgroundColor: 'rgba(90, 200, 168, 0.1)',
|
||||||
},
|
},
|
||||||
sendButton: {
|
sendButton: {
|
||||||
width: 44,
|
width: 44,
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
|
|||||||
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
import { AuthProvider, useAuth } from '@/contexts/AuthContext';
|
||||||
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
import { BeneficiaryProvider } from '@/contexts/BeneficiaryContext';
|
||||||
import { VoiceTranscriptProvider } from '@/contexts/VoiceTranscriptContext';
|
import { VoiceTranscriptProvider } from '@/contexts/VoiceTranscriptContext';
|
||||||
|
import { VoiceCallProvider } from '@/contexts/VoiceCallContext';
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
|
import { FloatingCallBubble } from '@/components/FloatingCallBubble';
|
||||||
|
|
||||||
// Prevent auto-hiding splash screen
|
// Prevent auto-hiding splash screen
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
@ -53,6 +55,7 @@ function RootLayoutNav() {
|
|||||||
<Stack.Screen name="terms" options={{ presentation: 'modal' }} />
|
<Stack.Screen name="terms" options={{ presentation: 'modal' }} />
|
||||||
<Stack.Screen name="privacy" options={{ presentation: 'modal' }} />
|
<Stack.Screen name="privacy" options={{ presentation: 'modal' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<FloatingCallBubble />
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
@ -64,7 +67,9 @@ export default function RootLayout() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BeneficiaryProvider>
|
<BeneficiaryProvider>
|
||||||
<VoiceTranscriptProvider>
|
<VoiceTranscriptProvider>
|
||||||
<RootLayoutNav />
|
<VoiceCallProvider>
|
||||||
|
<RootLayoutNav />
|
||||||
|
</VoiceCallProvider>
|
||||||
</VoiceTranscriptProvider>
|
</VoiceTranscriptProvider>
|
||||||
</BeneficiaryProvider>
|
</BeneficiaryProvider>
|
||||||
</AuthProvider>
|
</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