Fix voice call race condition - ensure beneficiaryData is passed

- Fix race condition where connect() was called before beneficiaryData loaded
- Add connectCalledRef to prevent duplicate connect calls
- Wait for beneficiaryData.deploymentId before initiating call
- Add 5s timeout fallback for edge cases (API failure/no beneficiaries)
- Hide Debug tab, show only Julia tab in navigation
- Add Android keyboard layout fix for password fields
- Bump version to 1.0.5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-01-21 14:30:59 -08:00
parent 906213e620
commit 204cb87f05
4 changed files with 91 additions and 56 deletions

View File

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "WellNuo", "name": "WellNuo",
"slug": "WellNuo", "slug": "WellNuo",
"version": "1.0.4", "version": "1.0.5",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "wellnuo", "scheme": "wellnuo",
@ -28,6 +28,7 @@
}, },
"android": { "android": {
"package": "com.wellnuo.app", "package": "com.wellnuo.app",
"softwareKeyboardLayoutMode": "resize",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png", "foregroundImage": "./assets/images/android-icon-foreground.png",
@ -84,9 +85,9 @@
"extra": { "extra": {
"router": {}, "router": {},
"eas": { "eas": {
"projectId": "a845255d-c966-4f12-aa60-c452c2d0c60d" "projectId": "8618c68a-6942-47ec-94f5-787fdbe5c0b4"
} }
}, },
"owner": "serter20692" "owner": "rzmrzli"
} }
} }

View File

@ -56,7 +56,7 @@ export default function TabLayout() {
), ),
}} }}
/> />
{/* Voice tab hidden - using Debug for testing */} {/* Voice tab - HIDDEN (calls go through Julia tab chat screen) */}
<Tabs.Screen <Tabs.Screen
name="voice" name="voice"
options={{ options={{
@ -72,7 +72,7 @@ export default function TabLayout() {
), ),
}} }}
/> />
{/* Debug tab - hidden in production */} {/* Debug tab - HIDDEN, no longer needed */}
<Tabs.Screen <Tabs.Screen
name="debug" name="debug"
options={{ options={{

View File

@ -9,7 +9,7 @@
* Features: * Features:
* - Phone call-like UI with Julia avatar * - Phone call-like UI with Julia avatar
* - Call duration timer * - Call duration timer
* - Mute/unmute and speaker toggle * - Mute/unmute toggle
* - Proper cleanup on unmount * - Proper cleanup on unmount
*/ */
@ -22,7 +22,6 @@ import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext'; import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom'; import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom';
import { setAudioOutput } from '@/utils/audioSession';
import { api } from '@/services/api'; import { api } from '@/services/api';
import type { Beneficiary } from '@/types'; import type { Beneficiary } from '@/types';
import type { BeneficiaryData } from '@/services/livekitService'; import type { BeneficiaryData } from '@/services/livekitService';
@ -34,22 +33,23 @@ export default function VoiceCallScreen() {
const { clearTranscript, addTranscriptEntry } = useVoiceTranscript(); const { clearTranscript, addTranscriptEntry } = useVoiceTranscript();
const { currentBeneficiary } = useBeneficiary(); const { currentBeneficiary } = useBeneficiary();
// Speaker toggle state (default: speaker ON)
const [isSpeakerOn, setIsSpeakerOn] = useState(true);
// Beneficiary state for building beneficiaryData // Beneficiary state for building beneficiaryData
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]); const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
const [beneficiariesLoaded, setBeneficiariesLoaded] = useState(false);
// Load beneficiaries on mount // Load beneficiaries on mount
useEffect(() => { useEffect(() => {
const loadBeneficiaries = async () => { const loadBeneficiaries = async () => {
try { try {
const data = await api.getAllBeneficiaries(); const response = await api.getAllBeneficiaries();
if (data) { if (response.ok && response.data) {
setBeneficiaries(data); setBeneficiaries(response.data);
console.log('[VoiceCall] Beneficiaries loaded:', response.data.length);
} }
} catch (error) { } catch (error) {
console.warn('[VoiceCall] Failed to load beneficiaries:', error); console.warn('[VoiceCall] Failed to load beneficiaries:', error);
} finally {
setBeneficiariesLoaded(true);
} }
}; };
loadBeneficiaries(); loadBeneficiaries();
@ -57,19 +57,29 @@ export default function VoiceCallScreen() {
// Build beneficiaryData for voice agent // Build beneficiaryData for voice agent
const beneficiaryData = useMemo((): BeneficiaryData | undefined => { const beneficiaryData = useMemo((): BeneficiaryData | undefined => {
if (beneficiaries.length === 0) { // Safety check - ensure beneficiaries is an array
if (!Array.isArray(beneficiaries) || beneficiaries.length === 0) {
console.log('[VoiceCall] No beneficiaries yet, skipping beneficiaryData');
return undefined; return undefined;
} }
try {
// Build beneficiary_names_dict from all beneficiaries // Build beneficiary_names_dict from all beneficiaries
// Format: {"21": "papa", "69": "David"} // Format: {"21": "papa", "69": "David"}
const beneficiaryNamesDict: Record<string, string> = {}; const beneficiaryNamesDict: Record<string, string> = {};
beneficiaries.forEach(b => { beneficiaries.forEach(b => {
beneficiaryNamesDict[b.id.toString()] = b.name; // Safety: check that b exists and has id and name
if (b && b.id != null && b.name) {
beneficiaryNamesDict[String(b.id)] = b.name;
}
}); });
// Get deployment_id from current beneficiary or fallback to first one // Get deployment_id from current beneficiary or fallback to first one
const deploymentId = currentBeneficiary?.id?.toString() || beneficiaries[0]?.id?.toString() || '21'; const deploymentId = currentBeneficiary?.id != null
? String(currentBeneficiary.id)
: beneficiaries[0]?.id != null
? String(beneficiaries[0].id)
: '21';
console.log('[VoiceCall] BeneficiaryData:', { deploymentId, beneficiaryNamesDict }); console.log('[VoiceCall] BeneficiaryData:', { deploymentId, beneficiaryNamesDict });
@ -77,6 +87,10 @@ export default function VoiceCallScreen() {
deploymentId, deploymentId,
beneficiaryNamesDict, beneficiaryNamesDict,
}; };
} catch (error) {
console.error('[VoiceCall] Error building beneficiaryData:', error);
return undefined;
}
}, [beneficiaries, currentBeneficiary]); }, [beneficiaries, currentBeneficiary]);
// LiveKit hook - ALL logic is here // LiveKit hook - ALL logic is here
@ -104,16 +118,51 @@ export default function VoiceCallScreen() {
const rotateAnim = useRef(new Animated.Value(0)).current; const rotateAnim = useRef(new Animated.Value(0)).current;
const avatarScale = useRef(new Animated.Value(0.8)).current; const avatarScale = useRef(new Animated.Value(0.8)).current;
// Clear transcript and start call on mount // Clear transcript on mount
useEffect(() => { useEffect(() => {
clearTranscript(); clearTranscript();
connect();
return () => {
// Cleanup handled by the hook
};
}, []); }, []);
// Track if connect has been called to prevent duplicate calls
const connectCalledRef = useRef(false);
// Start call ONLY after beneficiaries are loaded AND beneficiaryData is ready
// IMPORTANT: We must wait for beneficiaryData to be populated!
// Without deploymentId, Julia AI agent won't know which beneficiary to talk about.
useEffect(() => {
// Prevent duplicate connect calls
if (connectCalledRef.current) return;
// Only connect when beneficiaryData has a valid deploymentId
if (beneficiariesLoaded && beneficiaryData?.deploymentId) {
console.log('[VoiceCall] Starting call with beneficiaryData:', JSON.stringify(beneficiaryData));
connectCalledRef.current = true;
connect();
} else if (beneficiariesLoaded) {
console.log('[VoiceCall] Waiting for beneficiaryData... Current state:', {
beneficiariesLoaded,
beneficiariesCount: beneficiaries.length,
beneficiaryData: beneficiaryData ? JSON.stringify(beneficiaryData) : 'undefined'
});
}
}, [beneficiariesLoaded, beneficiaryData, beneficiaries.length, connect]);
// Fallback: if beneficiaryData doesn't arrive in 5 seconds, connect anyway
// This handles edge cases where API fails or user has no beneficiaries
useEffect(() => {
if (connectCalledRef.current) return;
const timeout = setTimeout(() => {
if (!connectCalledRef.current && beneficiariesLoaded) {
console.warn('[VoiceCall] Timeout: beneficiaryData not ready after 5s, connecting without it');
connectCalledRef.current = true;
connect();
}
}, 5000);
return () => clearTimeout(timeout);
}, [beneficiariesLoaded, connect]);
// Navigate back on disconnect or error // Navigate back on disconnect or error
useEffect(() => { useEffect(() => {
if (state === 'disconnected' || state === 'error') { if (state === 'disconnected' || state === 'error') {
@ -189,13 +238,6 @@ export default function VoiceCallScreen() {
router.back(); router.back();
}; };
// Toggle speaker/earpiece
const handleToggleSpeaker = async () => {
const newSpeakerState = !isSpeakerOn;
setIsSpeakerOn(newSpeakerState);
await setAudioOutput(newSpeakerState);
};
// Format duration as MM:SS // Format duration as MM:SS
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@ -313,7 +355,7 @@ export default function VoiceCallScreen() {
</View> </View>
{/* Bottom controls - centered layout with 3 buttons */} {/* Bottom controls - centered layout with 2 buttons */}
<View style={styles.controls}> <View style={styles.controls}>
{/* Mute button */} {/* Mute button */}
<TouchableOpacity <TouchableOpacity
@ -329,20 +371,6 @@ export default function VoiceCallScreen() {
<Text style={styles.controlLabel}>{isMuted ? 'Unmute' : 'Mute'}</Text> <Text style={styles.controlLabel}>{isMuted ? 'Unmute' : 'Mute'}</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Speaker toggle button */}
<TouchableOpacity
style={[styles.controlButton, isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
disabled={!isActive}
>
<Ionicons
name={isSpeakerOn ? 'volume-high' : 'ear'}
size={28}
color={isSpeakerOn ? AppColors.success : AppColors.white}
/>
<Text style={styles.controlLabel}>{isSpeakerOn ? 'Speaker' : 'Earpiece'}</Text>
</TouchableOpacity>
{/* End call button */} {/* End call button */}
<TouchableOpacity style={styles.endCallButton} onPress={handleEndCall}> <TouchableOpacity style={styles.endCallButton} onPress={handleEndCall}>
<Ionicons name="call" size={32} color={AppColors.white} /> <Ionicons name="call" size={32} color={AppColors.white} />
@ -475,7 +503,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingVertical: Spacing.xl, paddingVertical: Spacing.xl,
paddingHorizontal: Spacing.lg, paddingHorizontal: Spacing.lg,
gap: 24, // Space between 3 buttons (Mute, Speaker, End Call) gap: 40, // Space between 2 buttons (Mute, End Call)
}, },
controlButton: { controlButton: {
alignItems: 'center', alignItems: 'center',

View File

@ -5,6 +5,7 @@ import {
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Platform,
type TextInputProps, type TextInputProps,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -121,6 +122,11 @@ const styles = StyleSheet.create({
paddingHorizontal: Spacing.md, paddingHorizontal: Spacing.md,
fontSize: FontSizes.base, fontSize: FontSizes.base,
color: AppColors.textPrimary, color: AppColors.textPrimary,
// Fix for Android password field text visibility
...(Platform.OS === 'android' && {
fontFamily: 'Roboto',
includeFontPadding: false,
}),
}, },
inputWithLeftIcon: { inputWithLeftIcon: {
paddingLeft: 0, paddingLeft: 0,