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:
parent
906213e620
commit
204cb87f05
7
app.json
7
app.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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,26 +57,40 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build beneficiary_names_dict from all beneficiaries
|
try {
|
||||||
// Format: {"21": "papa", "69": "David"}
|
// Build beneficiary_names_dict from all beneficiaries
|
||||||
const beneficiaryNamesDict: Record<string, string> = {};
|
// Format: {"21": "papa", "69": "David"}
|
||||||
beneficiaries.forEach(b => {
|
const beneficiaryNamesDict: Record<string, string> = {};
|
||||||
beneficiaryNamesDict[b.id.toString()] = b.name;
|
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
|
// 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 });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user