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;
+}