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": {
"name": "WellNuo",
"slug": "WellNuo",
"version": "1.0.4",
"version": "1.0.5",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "wellnuo",
@ -28,6 +28,7 @@
},
"android": {
"package": "com.wellnuo.app",
"softwareKeyboardLayoutMode": "resize",
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
@ -84,9 +85,9 @@
"extra": {
"router": {},
"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
name="voice"
options={{
@ -72,7 +72,7 @@ export default function TabLayout() {
),
}}
/>
{/* Debug tab - hidden in production */}
{/* Debug tab - HIDDEN, no longer needed */}
<Tabs.Screen
name="debug"
options={{

View File

@ -9,7 +9,7 @@
* Features:
* - Phone call-like UI with Julia avatar
* - Call duration timer
* - Mute/unmute and speaker toggle
* - Mute/unmute toggle
* - Proper cleanup on unmount
*/
@ -22,7 +22,6 @@ import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom';
import { setAudioOutput } from '@/utils/audioSession';
import { api } from '@/services/api';
import type { Beneficiary } from '@/types';
import type { BeneficiaryData } from '@/services/livekitService';
@ -34,22 +33,23 @@ export default function VoiceCallScreen() {
const { clearTranscript, addTranscriptEntry } = useVoiceTranscript();
const { currentBeneficiary } = useBeneficiary();
// Speaker toggle state (default: speaker ON)
const [isSpeakerOn, setIsSpeakerOn] = useState(true);
// Beneficiary state for building beneficiaryData
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
const [beneficiariesLoaded, setBeneficiariesLoaded] = useState(false);
// Load beneficiaries on mount
useEffect(() => {
const loadBeneficiaries = async () => {
try {
const data = await api.getAllBeneficiaries();
if (data) {
setBeneficiaries(data);
const response = await api.getAllBeneficiaries();
if (response.ok && response.data) {
setBeneficiaries(response.data);
console.log('[VoiceCall] Beneficiaries loaded:', response.data.length);
}
} catch (error) {
console.warn('[VoiceCall] Failed to load beneficiaries:', error);
} finally {
setBeneficiariesLoaded(true);
}
};
loadBeneficiaries();
@ -57,26 +57,40 @@ export default function VoiceCallScreen() {
// Build beneficiaryData for voice agent
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;
}
// Build beneficiary_names_dict from all beneficiaries
// Format: {"21": "papa", "69": "David"}
const beneficiaryNamesDict: Record<string, string> = {};
beneficiaries.forEach(b => {
beneficiaryNamesDict[b.id.toString()] = b.name;
});
try {
// Build beneficiary_names_dict from all beneficiaries
// Format: {"21": "papa", "69": "David"}
const beneficiaryNamesDict: Record<string, string> = {};
beneficiaries.forEach(b => {
// 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
const deploymentId = currentBeneficiary?.id?.toString() || beneficiaries[0]?.id?.toString() || '21';
// Get deployment_id from current beneficiary or fallback to first one
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 });
return {
deploymentId,
beneficiaryNamesDict,
};
return {
deploymentId,
beneficiaryNamesDict,
};
} catch (error) {
console.error('[VoiceCall] Error building beneficiaryData:', error);
return undefined;
}
}, [beneficiaries, currentBeneficiary]);
// LiveKit hook - ALL logic is here
@ -104,16 +118,51 @@ export default function VoiceCallScreen() {
const rotateAnim = useRef(new Animated.Value(0)).current;
const avatarScale = useRef(new Animated.Value(0.8)).current;
// Clear transcript and start call on mount
// Clear transcript on mount
useEffect(() => {
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
useEffect(() => {
if (state === 'disconnected' || state === 'error') {
@ -189,13 +238,6 @@ export default function VoiceCallScreen() {
router.back();
};
// Toggle speaker/earpiece
const handleToggleSpeaker = async () => {
const newSpeakerState = !isSpeakerOn;
setIsSpeakerOn(newSpeakerState);
await setAudioOutput(newSpeakerState);
};
// Format duration as MM:SS
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
@ -313,7 +355,7 @@ export default function VoiceCallScreen() {
</View>
{/* Bottom controls - centered layout with 3 buttons */}
{/* Bottom controls - centered layout with 2 buttons */}
<View style={styles.controls}>
{/* Mute button */}
<TouchableOpacity
@ -329,20 +371,6 @@ export default function VoiceCallScreen() {
<Text style={styles.controlLabel}>{isMuted ? 'Unmute' : 'Mute'}</Text>
</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 */}
<TouchableOpacity style={styles.endCallButton} onPress={handleEndCall}>
<Ionicons name="call" size={32} color={AppColors.white} />
@ -475,7 +503,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingVertical: Spacing.xl,
paddingHorizontal: Spacing.lg,
gap: 24, // Space between 3 buttons (Mute, Speaker, End Call)
gap: 40, // Space between 2 buttons (Mute, End Call)
},
controlButton: {
alignItems: 'center',

View File

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