Sergei 05f872d067 fix: voice session improvements - FAB stop, echo prevention, chat TTS
- FAB button now correctly stops session during speaking/processing states
- Echo prevention: STT stopped during TTS playback, results ignored during speaking
- Chat TTS only speaks when voice session is active (no auto-speak for text chat)
- Session stop now aborts in-flight API requests and prevents race conditions
- STT restarts after TTS with 800ms delay for audio focus release
- Pending interrupt transcript processed after TTS completion
- ChatContext added for message persistence across tab navigation
- VoiceFAB redesigned with state-based animations
- console.error replaced with console.warn across voice pipeline
- no-speech STT errors silenced (normal silence behavior)
2026-01-27 22:59:55 -08:00

376 lines
12 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, TouchableOpacity } from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { FullScreenError } from '@/components/ui/ErrorMessage';
// Use dev.kresoja.net for MobileAppLogin support
// After MobileAppLogin() is called on /login, it auto-redirects to /dashboard
const LOGIN_URL = 'https://dev.kresoja.net/login';
export default function BeneficiaryDashboardScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
const webViewRef = useRef<WebView>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [canGoBack, setCanGoBack] = useState(false);
const [authToken, setAuthToken] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [isTokenLoaded, setIsTokenLoaded] = useState(false);
const [hasCalledMobileLogin, setHasCalledMobileLogin] = useState(false);
const beneficiaryName = currentBeneficiary?.name || 'Dashboard';
// Load token, username, and userId from SecureStore
useEffect(() => {
const loadCredentials = async () => {
try {
const token = await SecureStore.getItemAsync('accessToken');
const user = await SecureStore.getItemAsync('userName');
const uid = await SecureStore.getItemAsync('userId');
setAuthToken(token);
setUserName(user);
setUserId(uid);
console.log('Loaded credentials for WebView:', { hasToken: !!token, user, uid });
} catch (err) {
console.warn('Failed to load credentials:', err);
} finally {
setIsTokenLoaded(true);
}
};
loadCredentials();
}, []);
// JavaScript to call MobileAppLogin after page loads
// MobileAppLogin sets is_mobile=1, saves auth2, and redirects to /dashboard
// This hides desktop navigation (login/logout/dashboard buttons)
const getMobileLoginScript = () => {
if (!authToken) return '';
return `
(function() {
try {
// Wait for window.MobileAppLogin to be available
var checkInterval = setInterval(function() {
if (typeof window.MobileAppLogin === 'function') {
clearInterval(checkInterval);
console.log('MobileAppLogin found, calling with auth data...');
var authData = {
username: '${userName || ''}',
token: '${authToken}',
user_id: ${userId || 'null'}
};
window.MobileAppLogin(authData);
console.log('MobileAppLogin called successfully');
}
}, 100);
// Timeout after 5 seconds
setTimeout(function() {
clearInterval(checkInterval);
console.log('MobileAppLogin timeout - function not found');
}, 5000);
} catch(e) {
console.warn('Failed to call MobileAppLogin:', e);
}
})();
true;
`;
};
const handleRefresh = () => {
setError(null);
setIsLoading(true);
setHasCalledMobileLogin(false); // Reset to call MobileAppLogin again
webViewRef.current?.reload();
};
const handleWebViewBack = () => {
if (canGoBack) {
webViewRef.current?.goBack();
}
};
const handleNavigationStateChange = (navState: any) => {
setCanGoBack(navState.canGoBack);
// Auto-relogin when session expires (redirected to /login)
// If we're on /login and MobileAppLogin was already called, session expired
const url = navState.url || '';
const isOnLoginPage = url.includes('/login');
if (isOnLoginPage && hasCalledMobileLogin && authToken) {
console.log('[Dashboard] Session expired, re-authenticating...');
// Reset and call MobileAppLogin again
setHasCalledMobileLogin(false);
setTimeout(() => {
setHasCalledMobileLogin(true);
webViewRef.current?.injectJavaScript(getMobileLoginScript());
}, 1000);
}
};
const handleError = () => {
setError('Failed to load dashboard. Please check your internet connection.');
setIsLoading(false);
};
const handleGoBack = () => {
router.back();
};
// Wait for token to load before showing WebView
if (!isTokenLoaded) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Preparing dashboard...</Text>
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
<View style={styles.placeholder} />
</View>
<FullScreenError message={error} onRetry={handleRefresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={handleGoBack}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<View style={styles.headerCenter}>
{currentBeneficiary && (
<View style={styles.avatarSmall}>
<Text style={styles.avatarText}>
{currentBeneficiary.name.charAt(0).toUpperCase()}
</Text>
</View>
)}
<View>
<Text style={styles.headerTitle}>{beneficiaryName}</Text>
{currentBeneficiary?.relationship && (
<Text style={styles.headerSubtitle}>{currentBeneficiary.relationship}</Text>
)}
</View>
</View>
<View style={styles.headerActions}>
{canGoBack && (
<TouchableOpacity style={styles.actionButton} onPress={handleWebViewBack}>
<Ionicons name="chevron-back" size={22} color={AppColors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.actionButton} onPress={handleRefresh}>
<Ionicons name="refresh" size={22} color={AppColors.primary} />
</TouchableOpacity>
</View>
</View>
{/* WebView */}
<View style={styles.webViewContainer}>
<WebView
ref={webViewRef}
source={{ uri: LOGIN_URL }}
style={styles.webView}
onLoadStart={() => setIsLoading(true)}
onLoadEnd={() => {
setIsLoading(false);
// Call MobileAppLogin only once after first load
if (!hasCalledMobileLogin && authToken) {
setHasCalledMobileLogin(true);
// Small delay to ensure React app has mounted
setTimeout(() => {
webViewRef.current?.injectJavaScript(getMobileLoginScript());
}, 500);
}
}}
onError={handleError}
onHttpError={handleError}
onNavigationStateChange={handleNavigationStateChange}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={true}
scalesPageToFit={true}
allowsBackForwardNavigationGestures={true}
renderLoading={() => (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.loadingText}>Loading dashboard...</Text>
</View>
)}
/>
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color={AppColors.primary} />
</View>
)}
</View>
{/* Bottom Quick Actions */}
<View style={styles.bottomBar}>
<TouchableOpacity
style={styles.quickAction}
onPress={() => {
if (currentBeneficiary) {
setCurrentBeneficiary(currentBeneficiary);
}
router.push('/(tabs)/chat');
}}
>
<Ionicons name="chatbubble-ellipses" size={24} color={AppColors.primary} />
<Text style={styles.quickActionText}>Ask Julia</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.quickAction}
onPress={() => router.push(`./`)}
>
<Ionicons name="person" size={24} color={AppColors.primary} />
<Text style={styles.quickActionText}>Details</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.background,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
backButton: {
padding: Spacing.xs,
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginLeft: Spacing.sm,
},
avatarSmall: {
width: 36,
height: 36,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.primaryLight,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
avatarText: {
fontSize: FontSizes.base,
fontWeight: '600',
color: AppColors.white,
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: '700',
color: AppColors.textPrimary,
},
headerSubtitle: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
actionButton: {
padding: Spacing.xs,
marginLeft: Spacing.xs,
},
placeholder: {
width: 32,
},
webViewContainer: {
flex: 1,
},
webView: {
flex: 1,
},
loadingContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: AppColors.background,
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.8)',
},
loadingText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
bottomBar: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
backgroundColor: AppColors.background,
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
quickAction: {
alignItems: 'center',
padding: Spacing.sm,
},
quickActionText: {
fontSize: FontSizes.xs,
color: AppColors.primary,
marginTop: Spacing.xs,
},
});