Add DEV voice picker with 35+ languages for TTS testing
Voice assistant enhancements: - DEV-only voice picker modal for testing TTS voices - Support for 35+ languages: English variants, European, Asian, Middle Eastern - Each voice can be tested with localized sample text - Speech recognition enabled for voice input - Continuous conversation mode with auto-listening 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ec63a2c1e2
commit
40646622b8
7
app.json
7
app.json
@ -45,7 +45,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-speech-recognition"
|
"expo-speech-recognition",
|
||||||
|
"expo-audio"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
@ -54,9 +55,9 @@
|
|||||||
"extra": {
|
"extra": {
|
||||||
"router": {},
|
"router": {},
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "4a77e46d-7b0e-4ace-a385-006b07027234"
|
"projectId": "4f415b4b-41c8-4b98-989c-32f6b3f97481"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "kosyakorel1"
|
"owner": "serter2069ya"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
@ -17,34 +16,44 @@ import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
|||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const { login, isLoading, error, clearError } = useAuth();
|
const { requestOtp, isLoading, error, clearError } = useAuth();
|
||||||
const [username, setUsername] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleLogin = useCallback(async () => {
|
const validateEmail = (email: string): boolean => {
|
||||||
// Clear previous errors
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = useCallback(async () => {
|
||||||
clearError();
|
clearError();
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
|
|
||||||
// Validate
|
const trimmedEmail = email.trim().toLowerCase();
|
||||||
if (!username.trim()) {
|
|
||||||
setValidationError('Username is required');
|
if (!trimmedEmail) {
|
||||||
return;
|
setValidationError('Email is required');
|
||||||
}
|
|
||||||
if (!password.trim()) {
|
|
||||||
setValidationError('Password is required');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await login({ username: username.trim(), password });
|
if (!validateEmail(trimmedEmail)) {
|
||||||
|
setValidationError('Please enter a valid email address');
|
||||||
if (success) {
|
return;
|
||||||
// Clear password from memory after successful login
|
|
||||||
setPassword('');
|
|
||||||
router.replace('/(tabs)');
|
|
||||||
}
|
}
|
||||||
}, [username, password, login, clearError]);
|
|
||||||
|
const result = await requestOtp(trimmedEmail);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Navigate to OTP verification screen
|
||||||
|
router.push({
|
||||||
|
pathname: '/(auth)/verify-otp',
|
||||||
|
params: {
|
||||||
|
email: trimmedEmail,
|
||||||
|
skipOtp: result.skipOtp ? '1' : '0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [email, requestOtp, clearError]);
|
||||||
|
|
||||||
const displayError = validationError || error?.message;
|
const displayError = validationError || error?.message;
|
||||||
|
|
||||||
@ -65,8 +74,10 @@ export default function LoginScreen() {
|
|||||||
style={styles.logo}
|
style={styles.logo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.title}>Welcome Back</Text>
|
<Text style={styles.title}>Welcome to WellNuo</Text>
|
||||||
<Text style={styles.subtitle}>Sign in to continue monitoring your loved ones</Text>
|
<Text style={styles.subtitle}>
|
||||||
|
Enter your email to sign in or create an account
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
@ -82,53 +93,37 @@ export default function LoginScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Username"
|
label="Email"
|
||||||
placeholder="Enter your username"
|
placeholder="Enter your email"
|
||||||
leftIcon="person-outline"
|
leftIcon="mail-outline"
|
||||||
value={username}
|
value={email}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
setUsername(text);
|
setEmail(text);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
}}
|
}}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
editable={!isLoading}
|
editable={!isLoading}
|
||||||
/>
|
onSubmitEditing={handleContinue}
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
leftIcon="lock-closed-outline"
|
|
||||||
secureTextEntry
|
|
||||||
value={password}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setPassword(text);
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
editable={!isLoading}
|
|
||||||
onSubmitEditing={handleLogin}
|
|
||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.forgotPassword} onPress={() => router.push('/(auth)/forgot-password')}>
|
|
||||||
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Sign In"
|
title="Continue"
|
||||||
onPress={handleLogin}
|
onPress={handleContinue}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
|
style={styles.button}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Info */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.infoContainer}>
|
||||||
<Text style={styles.footerText}>Don't have an account? </Text>
|
<Text style={styles.infoText}>
|
||||||
<TouchableOpacity onPress={() => router.push('/(auth)/register')}>
|
We'll send you a verification code to confirm your email
|
||||||
<Text style={styles.footerLink}>Create Account</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Version Info */}
|
{/* Version Info */}
|
||||||
@ -168,38 +163,27 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
marginBottom: Spacing.xl,
|
marginBottom: Spacing.xl,
|
||||||
},
|
},
|
||||||
forgotPassword: {
|
button: {
|
||||||
alignSelf: 'flex-end',
|
marginTop: Spacing.md,
|
||||||
marginBottom: Spacing.lg,
|
|
||||||
marginTop: -Spacing.sm,
|
|
||||||
},
|
},
|
||||||
forgotPasswordText: {
|
infoContainer: {
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: Spacing.xl,
|
marginBottom: Spacing.xl,
|
||||||
},
|
},
|
||||||
footerText: {
|
infoText: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.sm,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textMuted,
|
||||||
},
|
textAlign: 'center',
|
||||||
footerLink: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: FontSizes.xs,
|
fontSize: FontSizes.xs,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
|
marginTop: 'auto',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,8 +18,7 @@ import { Ionicons, Feather } from '@expo/vector-icons';
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
import * as SecureStore from 'expo-secure-store';
|
||||||
import * as Speech from 'expo-speech';
|
import * as Speech from 'expo-speech';
|
||||||
// NOTE: expo-speech-recognition does not work in Expo Go, so we disable voice input
|
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from 'expo-speech-recognition';
|
||||||
// import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from 'expo-speech-recognition';
|
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
||||||
@ -27,6 +26,66 @@ import type { Message, Beneficiary } from '@/types';
|
|||||||
|
|
||||||
const OLD_API_URL = 'https://eluxnetworks.net/function/well-api/api';
|
const OLD_API_URL = 'https://eluxnetworks.net/function/well-api/api';
|
||||||
|
|
||||||
|
// DEV ONLY: Voice options for testing different TTS voices
|
||||||
|
const DEV_MODE = __DEV__;
|
||||||
|
|
||||||
|
interface VoiceOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
language: string;
|
||||||
|
voice?: string; // iOS voice identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available iOS voices for testing
|
||||||
|
const AVAILABLE_VOICES: VoiceOption[] = [
|
||||||
|
// English voices
|
||||||
|
{ id: 'en-US-default', name: 'English (US) - Default', language: 'en-US' },
|
||||||
|
{ id: 'en-US-samantha', name: 'Samantha (US)', language: 'en-US', voice: 'com.apple.ttsbundle.Samantha-compact' },
|
||||||
|
{ id: 'en-GB-daniel', name: 'Daniel (UK)', language: 'en-GB', voice: 'com.apple.ttsbundle.Daniel-compact' },
|
||||||
|
{ id: 'en-AU-karen', name: 'Karen (Australia)', language: 'en-AU', voice: 'com.apple.ttsbundle.Karen-compact' },
|
||||||
|
{ id: 'en-IE-moira', name: 'Moira (Ireland)', language: 'en-IE', voice: 'com.apple.ttsbundle.Moira-compact' },
|
||||||
|
{ id: 'en-ZA-tessa', name: 'Tessa (South Africa)', language: 'en-ZA', voice: 'com.apple.ttsbundle.Tessa-compact' },
|
||||||
|
{ id: 'en-IN-rishi', name: 'Rishi (India)', language: 'en-IN', voice: 'com.apple.ttsbundle.Rishi-compact' },
|
||||||
|
|
||||||
|
// European languages
|
||||||
|
{ id: 'fr-FR', name: 'French (France)', language: 'fr-FR' },
|
||||||
|
{ id: 'de-DE', name: 'German', language: 'de-DE' },
|
||||||
|
{ id: 'es-ES', name: 'Spanish (Spain)', language: 'es-ES' },
|
||||||
|
{ id: 'es-MX', name: 'Spanish (Mexico)', language: 'es-MX' },
|
||||||
|
{ id: 'it-IT', name: 'Italian', language: 'it-IT' },
|
||||||
|
{ id: 'pt-BR', name: 'Portuguese (Brazil)', language: 'pt-BR' },
|
||||||
|
{ id: 'pt-PT', name: 'Portuguese (Portugal)', language: 'pt-PT' },
|
||||||
|
{ id: 'nl-NL', name: 'Dutch', language: 'nl-NL' },
|
||||||
|
{ id: 'pl-PL', name: 'Polish', language: 'pl-PL' },
|
||||||
|
{ id: 'ru-RU', name: 'Russian', language: 'ru-RU' },
|
||||||
|
{ id: 'uk-UA', name: 'Ukrainian', language: 'uk-UA' },
|
||||||
|
{ id: 'cs-CZ', name: 'Czech', language: 'cs-CZ' },
|
||||||
|
{ id: 'da-DK', name: 'Danish', language: 'da-DK' },
|
||||||
|
{ id: 'fi-FI', name: 'Finnish', language: 'fi-FI' },
|
||||||
|
{ id: 'el-GR', name: 'Greek', language: 'el-GR' },
|
||||||
|
{ id: 'hu-HU', name: 'Hungarian', language: 'hu-HU' },
|
||||||
|
{ id: 'no-NO', name: 'Norwegian', language: 'no-NO' },
|
||||||
|
{ id: 'ro-RO', name: 'Romanian', language: 'ro-RO' },
|
||||||
|
{ id: 'sk-SK', name: 'Slovak', language: 'sk-SK' },
|
||||||
|
{ id: 'sv-SE', name: 'Swedish', language: 'sv-SE' },
|
||||||
|
{ id: 'tr-TR', name: 'Turkish', language: 'tr-TR' },
|
||||||
|
|
||||||
|
// Asian languages
|
||||||
|
{ id: 'zh-CN', name: 'Chinese (Mandarin)', language: 'zh-CN' },
|
||||||
|
{ id: 'zh-TW', name: 'Chinese (Taiwan)', language: 'zh-TW' },
|
||||||
|
{ id: 'zh-HK', name: 'Chinese (Cantonese)', language: 'zh-HK' },
|
||||||
|
{ id: 'ja-JP', name: 'Japanese', language: 'ja-JP' },
|
||||||
|
{ id: 'ko-KR', name: 'Korean', language: 'ko-KR' },
|
||||||
|
{ id: 'hi-IN', name: 'Hindi', language: 'hi-IN' },
|
||||||
|
{ id: 'th-TH', name: 'Thai', language: 'th-TH' },
|
||||||
|
{ id: 'vi-VN', name: 'Vietnamese', language: 'vi-VN' },
|
||||||
|
{ id: 'id-ID', name: 'Indonesian', language: 'id-ID' },
|
||||||
|
|
||||||
|
// Middle Eastern
|
||||||
|
{ id: 'ar-SA', name: 'Arabic', language: 'ar-SA' },
|
||||||
|
{ id: 'he-IL', name: 'Hebrew', language: 'he-IL' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ActivityData {
|
interface ActivityData {
|
||||||
name: string;
|
name: string;
|
||||||
rooms: Array<{
|
rooms: Array<{
|
||||||
@ -63,20 +122,58 @@ export default function VoiceAIScreen() {
|
|||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: 'Hello! I\'m Julia, your voice assistant for monitoring your loved ones. Select a beneficiary and type your question to get started.',
|
content: 'Hello! I\'m Julia, your voice assistant for monitoring your loved ones. Select a beneficiary and tap the microphone to ask a question.',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||||
|
const [isListening, setIsListening] = useState(false);
|
||||||
|
const [recognizedText, setRecognizedText] = useState('');
|
||||||
const [showBeneficiaryPicker, setShowBeneficiaryPicker] = useState(false);
|
const [showBeneficiaryPicker, setShowBeneficiaryPicker] = useState(false);
|
||||||
|
const [isContinuousMode, setIsContinuousMode] = useState(false); // Live chat mode
|
||||||
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
|
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
|
||||||
|
// DEV ONLY: Voice selection for testing
|
||||||
|
const [selectedVoice, setSelectedVoice] = useState<VoiceOption>(AVAILABLE_VOICES[0]);
|
||||||
|
const [showVoicePicker, setShowVoicePicker] = useState(false);
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const lastSendTimeRef = useRef<number>(0);
|
const lastSendTimeRef = useRef<number>(0);
|
||||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
const SEND_COOLDOWN_MS = 1000;
|
const SEND_COOLDOWN_MS = 1000;
|
||||||
|
|
||||||
|
// Speech recognition event handlers
|
||||||
|
useSpeechRecognitionEvent('start', () => {
|
||||||
|
setIsListening(true);
|
||||||
|
setRecognizedText('');
|
||||||
|
});
|
||||||
|
|
||||||
|
useSpeechRecognitionEvent('end', () => {
|
||||||
|
setIsListening(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
useSpeechRecognitionEvent('result', (event) => {
|
||||||
|
const transcript = event.results[0]?.transcript || '';
|
||||||
|
setRecognizedText(transcript);
|
||||||
|
|
||||||
|
// If final result, send to AI
|
||||||
|
if (event.isFinal && transcript.trim()) {
|
||||||
|
setInput(transcript);
|
||||||
|
// Auto-send after speech recognition completes
|
||||||
|
setTimeout(() => {
|
||||||
|
handleSendWithText(transcript);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useSpeechRecognitionEvent('error', (event) => {
|
||||||
|
console.log('Speech recognition error:', event.error, event.message);
|
||||||
|
setIsListening(false);
|
||||||
|
if (event.error !== 'no-speech') {
|
||||||
|
Alert.alert('Voice Error', event.message || 'Could not recognize speech. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Load beneficiaries on mount
|
// Load beneficiaries on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBeneficiaries();
|
loadBeneficiaries();
|
||||||
@ -117,6 +214,8 @@ export default function VoiceAIScreen() {
|
|||||||
// Fetch activity data and format it as context
|
// Fetch activity data and format it as context
|
||||||
const getActivityContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
|
const getActivityContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
|
console.log('Fetching activity context for deployment:', deploymentId);
|
||||||
|
|
||||||
const response = await fetch(OLD_API_URL, {
|
const response = await fetch(OLD_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
@ -130,14 +229,19 @@ export default function VoiceAIScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const data: ActivitiesResponse = await response.json();
|
const data: ActivitiesResponse = await response.json();
|
||||||
|
console.log('Activity API response:', JSON.stringify(data).slice(0, 200));
|
||||||
|
|
||||||
if (!data.chart_data || data.chart_data.length === 0) {
|
if (!data.chart_data || data.chart_data.length === 0) {
|
||||||
|
console.log('No chart_data in response');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get weekly data (most recent)
|
// Get weekly data (most recent)
|
||||||
const weeklyData = data.chart_data.find(d => d.name === 'Weekly');
|
const weeklyData = data.chart_data.find(d => d.name === 'Weekly');
|
||||||
if (!weeklyData) return '';
|
if (!weeklyData) {
|
||||||
|
console.log('No Weekly data found');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// Build context string
|
// Build context string
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@ -169,13 +273,68 @@ export default function VoiceAIScreen() {
|
|||||||
lines.push(`Weekly summary: ${weeklyStats.join(', ')}`);
|
lines.push(`Weekly summary: ${weeklyStats.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('. ');
|
const result = lines.join('. ');
|
||||||
|
console.log('Activity context result:', result);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Failed to fetch activity context:', error);
|
console.log('Failed to fetch activity context:', error);
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch dashboard data as fallback context
|
||||||
|
const getDashboardContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const response = await fetch(OLD_API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
function: 'dashboard_single',
|
||||||
|
user_name: userName,
|
||||||
|
token: token,
|
||||||
|
deployment_id: deploymentId,
|
||||||
|
date: today,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Dashboard API response:', JSON.stringify(data).slice(0, 300));
|
||||||
|
|
||||||
|
if (!data.result_list || data.result_list.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = data.result_list[0];
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (info.wellness_descriptor) {
|
||||||
|
lines.push(`Current wellness: ${info.wellness_descriptor}`);
|
||||||
|
}
|
||||||
|
if (info.wellness_score_percent) {
|
||||||
|
lines.push(`Wellness score: ${info.wellness_score_percent}%`);
|
||||||
|
}
|
||||||
|
if (info.last_location) {
|
||||||
|
lines.push(`Last seen in: ${info.last_location}`);
|
||||||
|
}
|
||||||
|
if (info.last_detected_time) {
|
||||||
|
lines.push(`Last activity: ${info.last_detected_time}`);
|
||||||
|
}
|
||||||
|
if (info.sleep_hours) {
|
||||||
|
lines.push(`Sleep hours: ${info.sleep_hours}`);
|
||||||
|
}
|
||||||
|
if (info.temperature) {
|
||||||
|
lines.push(`Temperature: ${info.temperature}${info.units === 'F' ? '°F' : '°C'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('. ');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to fetch dashboard context:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sendToVoiceAsk = async (question: string): Promise<string> => {
|
const sendToVoiceAsk = async (question: string): Promise<string> => {
|
||||||
const token = await SecureStore.getItemAsync('accessToken');
|
const token = await SecureStore.getItemAsync('accessToken');
|
||||||
const userName = await SecureStore.getItemAsync('userName');
|
const userName = await SecureStore.getItemAsync('userName');
|
||||||
@ -188,38 +347,65 @@ export default function VoiceAIScreen() {
|
|||||||
throw new Error('Please select a beneficiary first');
|
throw new Error('Please select a beneficiary first');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get activity context to include with the question
|
|
||||||
const activityContext = await getActivityContext(
|
|
||||||
token,
|
|
||||||
userName,
|
|
||||||
currentBeneficiary.id.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build enhanced question with context
|
|
||||||
const beneficiaryName = currentBeneficiary.name || 'the patient';
|
const beneficiaryName = currentBeneficiary.name || 'the patient';
|
||||||
let enhancedQuestion = question;
|
const deploymentId = currentBeneficiary.id.toString();
|
||||||
|
|
||||||
|
// Get activity context (primary source)
|
||||||
|
let activityContext = await getActivityContext(token, userName, deploymentId);
|
||||||
|
|
||||||
|
// If activity context is empty, try dashboard context as fallback
|
||||||
|
if (!activityContext) {
|
||||||
|
console.log('Activity context empty, trying dashboard...');
|
||||||
|
activityContext = await getDashboardContext(token, userName, deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the question with embedded context
|
||||||
|
// Format it clearly so the LLM understands this is data about the person
|
||||||
|
let enhancedQuestion: string;
|
||||||
|
|
||||||
if (activityContext) {
|
if (activityContext) {
|
||||||
enhancedQuestion = `About ${beneficiaryName}: ${activityContext}. User question: ${question}`;
|
enhancedQuestion = `You are a caring assistant helping monitor ${beneficiaryName}'s wellbeing.
|
||||||
|
|
||||||
|
Here is the current data about ${beneficiaryName}:
|
||||||
|
${activityContext}
|
||||||
|
|
||||||
|
Based on this data, please answer the following question: ${question}`;
|
||||||
} else {
|
} else {
|
||||||
enhancedQuestion = `About ${beneficiaryName}: ${question}`;
|
// No context available - still try to answer
|
||||||
|
enhancedQuestion = `You are a caring assistant helping monitor ${beneficiaryName}'s wellbeing. Please answer: ${question}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('=== Voice API Debug ===');
|
||||||
|
console.log('Beneficiary Name:', beneficiaryName);
|
||||||
|
console.log('Activity Context Length:', activityContext?.length || 0);
|
||||||
|
console.log('Activity Context:', activityContext || 'EMPTY');
|
||||||
|
console.log('Deployment ID:', deploymentId);
|
||||||
|
|
||||||
|
const requestBody = new URLSearchParams({
|
||||||
|
function: 'voice_ask',
|
||||||
|
clientId: '001',
|
||||||
|
user_name: userName,
|
||||||
|
token: token,
|
||||||
|
question: enhancedQuestion,
|
||||||
|
deployment_id: deploymentId,
|
||||||
|
// Also try sending context as separate parameter in case API supports it
|
||||||
|
context: activityContext || '',
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
console.log('Request Body (first 500 chars):', requestBody.slice(0, 500));
|
||||||
|
|
||||||
const response = await fetch(OLD_API_URL, {
|
const response = await fetch(OLD_API_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: requestBody,
|
||||||
function: 'voice_ask',
|
|
||||||
clientId: '001',
|
|
||||||
user_name: userName,
|
|
||||||
token: token,
|
|
||||||
question: enhancedQuestion,
|
|
||||||
deployment_id: currentBeneficiary.id.toString(),
|
|
||||||
}).toString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data: VoiceAskResponse = await response.json();
|
const data: VoiceAskResponse = await response.json();
|
||||||
|
|
||||||
|
console.log('=== Voice API Response ===');
|
||||||
|
console.log('Full Response:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
if (data.ok && data.response?.body) {
|
if (data.ok && data.response?.body) {
|
||||||
return data.response.body;
|
return data.response.body;
|
||||||
} else if (data.status === '401 Unauthorized') {
|
} else if (data.status === '401 Unauthorized') {
|
||||||
@ -229,22 +415,80 @@ export default function VoiceAIScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const speakResponse = async (text: string) => {
|
// Text-to-Speech using expo-speech (works out of the box)
|
||||||
|
const speakResponse = async (text: string, autoListenAfter: boolean = false) => {
|
||||||
setIsSpeaking(true);
|
setIsSpeaking(true);
|
||||||
try {
|
try {
|
||||||
await Speech.speak(text, {
|
const speechOptions: Speech.SpeechOptions = {
|
||||||
language: 'en-US',
|
language: selectedVoice.language,
|
||||||
pitch: 1.0,
|
pitch: 1.0,
|
||||||
rate: 0.9,
|
rate: 0.9,
|
||||||
onDone: () => setIsSpeaking(false),
|
onDone: () => {
|
||||||
|
setIsSpeaking(false);
|
||||||
|
if (autoListenAfter && isContinuousMode && currentBeneficiary?.id) {
|
||||||
|
setTimeout(() => {
|
||||||
|
startListeningInternal();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
onError: () => setIsSpeaking(false),
|
onError: () => setIsSpeaking(false),
|
||||||
});
|
};
|
||||||
|
// Add specific voice if available (iOS only)
|
||||||
|
if (selectedVoice.voice) {
|
||||||
|
speechOptions.voice = selectedVoice.voice;
|
||||||
|
}
|
||||||
|
await Speech.speak(text, speechOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('TTS error:', error);
|
console.error('TTS error:', error);
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DEV: Test voice with sample text
|
||||||
|
const testVoice = (voice: VoiceOption) => {
|
||||||
|
Speech.stop();
|
||||||
|
const testText = getTestTextForLanguage(voice.language);
|
||||||
|
const speechOptions: Speech.SpeechOptions = {
|
||||||
|
language: voice.language,
|
||||||
|
pitch: 1.0,
|
||||||
|
rate: 0.9,
|
||||||
|
};
|
||||||
|
if (voice.voice) {
|
||||||
|
speechOptions.voice = voice.voice;
|
||||||
|
}
|
||||||
|
Speech.speak(testText, speechOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get appropriate test text for each language
|
||||||
|
const getTestTextForLanguage = (language: string): string => {
|
||||||
|
const testTexts: Record<string, string> = {
|
||||||
|
'en-US': 'Hello! I am Julia, your voice assistant. How can I help you today?',
|
||||||
|
'en-GB': 'Hello! I am Julia, your voice assistant. How can I help you today?',
|
||||||
|
'en-AU': 'Hello! I am Julia, your voice assistant. How can I help you today?',
|
||||||
|
'en-IE': 'Hello! I am Julia, your voice assistant. How can I help you today?',
|
||||||
|
'en-ZA': 'Hello! I am Julia, your voice assistant. How can I help you today?',
|
||||||
|
'en-IN': 'Hello! I am Julia, your voice assistant. How can I help you today?',
|
||||||
|
'fr-FR': 'Bonjour! Je suis Julia, votre assistante vocale. Comment puis-je vous aider?',
|
||||||
|
'de-DE': 'Hallo! Ich bin Julia, Ihre Sprachassistentin. Wie kann ich Ihnen helfen?',
|
||||||
|
'es-ES': 'Hola! Soy Julia, tu asistente de voz. ¿Cómo puedo ayudarte?',
|
||||||
|
'es-MX': 'Hola! Soy Julia, tu asistente de voz. ¿Cómo puedo ayudarte?',
|
||||||
|
'it-IT': 'Ciao! Sono Julia, la tua assistente vocale. Come posso aiutarti?',
|
||||||
|
'pt-BR': 'Olá! Sou Julia, sua assistente de voz. Como posso ajudá-lo?',
|
||||||
|
'pt-PT': 'Olá! Sou a Julia, a sua assistente de voz. Como posso ajudá-lo?',
|
||||||
|
'ru-RU': 'Привет! Я Юлия, ваш голосовой помощник. Чем могу помочь?',
|
||||||
|
'uk-UA': 'Привіт! Я Юлія, ваш голосовий помічник. Чим можу допомогти?',
|
||||||
|
'zh-CN': '你好!我是朱莉娅,您的语音助手。我能帮您什么?',
|
||||||
|
'zh-TW': '你好!我是茱莉亞,您的語音助手。我能幫您什麼?',
|
||||||
|
'zh-HK': '你好!我係Julia,你嘅語音助手。有咩可以幫到你?',
|
||||||
|
'ja-JP': 'こんにちは!私はジュリア、あなたの音声アシスタントです。何かお手伝いできますか?',
|
||||||
|
'ko-KR': '안녕하세요! 저는 줄리아, 당신의 음성 비서입니다. 어떻게 도와드릴까요?',
|
||||||
|
'ar-SA': 'مرحبا! أنا جوليا، مساعدتك الصوتية. كيف يمكنني مساعدتك؟',
|
||||||
|
'he-IL': 'שלום! אני ג׳וליה, העוזרת הקולית שלך. איך אוכל לעזור לך?',
|
||||||
|
'hi-IN': 'नमस्ते! मैं जूलिया हूं, आपकी वॉयस असिस्टेंट। मैं आपकी कैसे मदद कर सकती हूं?',
|
||||||
|
};
|
||||||
|
return testTexts[language] || testTexts['en-US'];
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
const trimmedInput = input.trim();
|
const trimmedInput = input.trim();
|
||||||
if (!trimmedInput || isSending) return;
|
if (!trimmedInput || isSending) return;
|
||||||
@ -317,17 +561,130 @@ export default function VoiceAIScreen() {
|
|||||||
speakResponse(`Ready to answer questions about ${beneficiary.name}`);
|
speakResponse(`Ready to answer questions about ${beneficiary.name}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopSpeaking = () => {
|
const stopSpeaking = async () => {
|
||||||
Speech.stop();
|
Speech.stop();
|
||||||
setIsSpeaking(false);
|
setIsSpeaking(false);
|
||||||
|
setIsContinuousMode(false); // Also stop continuous mode when user stops speaking
|
||||||
};
|
};
|
||||||
|
|
||||||
const showMicNotAvailable = () => {
|
// Internal function to start listening (no permission check, used for continuous mode)
|
||||||
Alert.alert(
|
const startListeningInternal = () => {
|
||||||
'Voice Input Not Available',
|
if (isSending || isSpeaking) return;
|
||||||
'Voice recognition is not available in Expo Go. Please type your question instead.',
|
if (!currentBeneficiary?.id) return;
|
||||||
[{ text: 'OK' }]
|
|
||||||
);
|
// Stop any ongoing speech
|
||||||
|
Speech.stop();
|
||||||
|
setIsSpeaking(false);
|
||||||
|
|
||||||
|
// Start recognition
|
||||||
|
ExpoSpeechRecognitionModule.start({
|
||||||
|
lang: 'en-US',
|
||||||
|
interimResults: true,
|
||||||
|
maxAlternatives: 1,
|
||||||
|
continuous: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start voice recognition (user-initiated)
|
||||||
|
const startListening = async () => {
|
||||||
|
if (isSending || isSpeaking) return;
|
||||||
|
|
||||||
|
// Require beneficiary selection
|
||||||
|
if (!currentBeneficiary?.id) {
|
||||||
|
Alert.alert(
|
||||||
|
'Select Beneficiary',
|
||||||
|
'Please select a beneficiary first to ask questions about their wellbeing.',
|
||||||
|
[{ text: 'Select', onPress: () => setShowBeneficiaryPicker(true) }, { text: 'Cancel' }]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permissions
|
||||||
|
const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
|
||||||
|
if (!result.granted) {
|
||||||
|
Alert.alert(
|
||||||
|
'Microphone Permission Required',
|
||||||
|
'Please grant microphone permission to use voice input.',
|
||||||
|
[{ text: 'OK' }]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable continuous mode when user starts listening
|
||||||
|
setIsContinuousMode(true);
|
||||||
|
|
||||||
|
// Stop any ongoing speech
|
||||||
|
Speech.stop();
|
||||||
|
setIsSpeaking(false);
|
||||||
|
|
||||||
|
// Start recognition
|
||||||
|
ExpoSpeechRecognitionModule.start({
|
||||||
|
lang: 'en-US',
|
||||||
|
interimResults: true,
|
||||||
|
maxAlternatives: 1,
|
||||||
|
continuous: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop voice recognition and disable continuous mode
|
||||||
|
const stopListening = () => {
|
||||||
|
ExpoSpeechRecognitionModule.stop();
|
||||||
|
setIsListening(false);
|
||||||
|
setIsContinuousMode(false); // User manually stopped, disable continuous mode
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle send with specific text (used by speech recognition)
|
||||||
|
const handleSendWithText = async (text: string) => {
|
||||||
|
const trimmedInput = text.trim();
|
||||||
|
if (!trimmedInput || isSending) return;
|
||||||
|
|
||||||
|
if (!currentBeneficiary?.id) return;
|
||||||
|
|
||||||
|
// Debounce
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastSendTimeRef.current < SEND_COOLDOWN_MS) return;
|
||||||
|
lastSendTimeRef.current = now;
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content: trimmedInput,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInput('');
|
||||||
|
setRecognizedText('');
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiResponse = await sendToVoiceAsk(trimmedInput);
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiResponse,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, assistantMessage]);
|
||||||
|
|
||||||
|
// Speak the response - in continuous mode, auto-listen after speaking
|
||||||
|
await speakResponse(aiResponse, true);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Sorry, I encountered an error: ${error instanceof Error ? error.message : 'Unknown error'}. Please try again.`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, errorMessage]);
|
||||||
|
// Even on error, continue listening in continuous mode
|
||||||
|
if (isContinuousMode && currentBeneficiary?.id) {
|
||||||
|
setTimeout(() => startListeningInternal(), 500);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMessage = ({ item }: { item: Message }) => {
|
const renderMessage = ({ item }: { item: Message }) => {
|
||||||
@ -365,20 +722,33 @@ export default function VoiceAIScreen() {
|
|||||||
<Text style={styles.headerSubtitle}>
|
<Text style={styles.headerSubtitle}>
|
||||||
{isSending
|
{isSending
|
||||||
? 'Thinking...'
|
? 'Thinking...'
|
||||||
: isSpeaking
|
: isListening
|
||||||
? 'Speaking...'
|
? 'Listening...'
|
||||||
: currentBeneficiary
|
: isSpeaking
|
||||||
? `Monitoring ${currentBeneficiary.name}`
|
? 'Speaking...'
|
||||||
: 'Select a beneficiary'}
|
: currentBeneficiary
|
||||||
|
? `Monitoring ${currentBeneficiary.name}`
|
||||||
|
: 'Select a beneficiary'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.beneficiaryButton} onPress={() => setShowBeneficiaryPicker(true)}>
|
<View style={styles.headerButtons}>
|
||||||
<Feather name="users" size={20} color={AppColors.primary} />
|
{/* DEV ONLY: Voice Settings */}
|
||||||
<Text style={styles.beneficiaryButtonText}>
|
{DEV_MODE && (
|
||||||
{currentBeneficiary?.name?.split(' ')[0] || 'Select'}
|
<TouchableOpacity
|
||||||
</Text>
|
style={styles.voiceSettingsButton}
|
||||||
</TouchableOpacity>
|
onPress={() => setShowVoicePicker(true)}
|
||||||
|
>
|
||||||
|
<Feather name="sliders" size={18} color="#9B59B6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.beneficiaryButton} onPress={() => setShowBeneficiaryPicker(true)}>
|
||||||
|
<Feather name="users" size={20} color={AppColors.primary} />
|
||||||
|
<Text style={styles.beneficiaryButtonText}>
|
||||||
|
{currentBeneficiary?.name?.split(' ')[0] || 'Select'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
@ -397,21 +767,52 @@ export default function VoiceAIScreen() {
|
|||||||
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
|
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Listening indicator */}
|
||||||
|
{isListening && (
|
||||||
|
<TouchableOpacity style={styles.listeningIndicator} onPress={stopListening}>
|
||||||
|
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||||
|
<Feather name="mic" size={20} color="#E74C3C" />
|
||||||
|
</Animated.View>
|
||||||
|
<Text style={styles.listeningText}>
|
||||||
|
{recognizedText || 'Listening... tap to stop'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Speaking indicator */}
|
{/* Speaking indicator */}
|
||||||
{isSpeaking && (
|
{isSpeaking && !isListening && (
|
||||||
<TouchableOpacity style={styles.speakingIndicator} onPress={stopSpeaking}>
|
<TouchableOpacity style={styles.speakingIndicator} onPress={stopSpeaking}>
|
||||||
<Feather name="volume-2" size={20} color="#9B59B6" />
|
<Feather name="volume-2" size={20} color="#9B59B6" />
|
||||||
<Text style={styles.speakingText}>Speaking... tap to stop</Text>
|
<Text style={styles.speakingText}>
|
||||||
|
{isContinuousMode ? 'Live mode - Speaking... tap to stop' : 'Speaking... tap to stop'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Continuous mode indicator when idle */}
|
||||||
|
{isContinuousMode && !isListening && !isSpeaking && !isSending && (
|
||||||
|
<TouchableOpacity style={styles.continuousModeIndicator} onPress={() => setIsContinuousMode(false)}>
|
||||||
|
<Feather name="radio" size={20} color="#27AE60" />
|
||||||
|
<Text style={styles.continuousModeText}>Live chat active - tap to stop</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.micButton, styles.micButtonDisabled]}
|
style={[
|
||||||
onPress={showMicNotAvailable}
|
styles.micButton,
|
||||||
|
isListening && styles.micButtonActive,
|
||||||
|
isContinuousMode && !isListening && styles.micButtonContinuous
|
||||||
|
]}
|
||||||
|
onPress={isListening ? stopListening : startListening}
|
||||||
|
disabled={isSending}
|
||||||
>
|
>
|
||||||
<Feather name="mic-off" size={24} color={AppColors.textMuted} />
|
<Feather
|
||||||
|
name={isListening ? "mic" : "mic"}
|
||||||
|
size={24}
|
||||||
|
color={isListening ? AppColors.white : (isContinuousMode ? '#27AE60' : AppColors.primary)}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@ -480,6 +881,175 @@ export default function VoiceAIScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* DEV ONLY: Voice Picker Modal */}
|
||||||
|
{DEV_MODE && (
|
||||||
|
<Modal visible={showVoicePicker} animationType="slide" transparent>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={[styles.modalContent, { maxHeight: '80%' }]}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.modalTitle}>Voice Settings</Text>
|
||||||
|
<Text style={styles.devBadge}>DEV ONLY</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity onPress={() => setShowVoicePicker(false)}>
|
||||||
|
<Ionicons name="close" size={24} color={AppColors.textPrimary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Current Voice Info */}
|
||||||
|
<View style={styles.currentVoiceInfo}>
|
||||||
|
<Text style={styles.currentVoiceLabel}>Current: {selectedVoice.name}</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.testVoiceButton}
|
||||||
|
onPress={() => testVoice(selectedVoice)}
|
||||||
|
>
|
||||||
|
<Feather name="play" size={16} color={AppColors.white} />
|
||||||
|
<Text style={styles.testVoiceButtonText}>Test</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.voiceList}>
|
||||||
|
{/* English Voices Section */}
|
||||||
|
<Text style={styles.voiceSectionTitle}>English Voices</Text>
|
||||||
|
{AVAILABLE_VOICES.filter(v => v.language.startsWith('en-')).map(voice => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={voice.id}
|
||||||
|
style={[
|
||||||
|
styles.voiceItem,
|
||||||
|
selectedVoice.id === voice.id && styles.voiceItemSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedVoice(voice)}
|
||||||
|
>
|
||||||
|
<View style={styles.voiceItemInfo}>
|
||||||
|
<Text style={styles.voiceItemName}>{voice.name}</Text>
|
||||||
|
<Text style={styles.voiceItemLang}>{voice.language}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.voiceItemActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.playButton}
|
||||||
|
onPress={() => testVoice(voice)}
|
||||||
|
>
|
||||||
|
<Feather name="play-circle" size={24} color="#9B59B6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{selectedVoice.id === voice.id && (
|
||||||
|
<Feather name="check-circle" size={24} color={AppColors.success} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* European Languages Section */}
|
||||||
|
<Text style={styles.voiceSectionTitle}>European Languages</Text>
|
||||||
|
{AVAILABLE_VOICES.filter(v =>
|
||||||
|
['fr-FR', 'de-DE', 'es-ES', 'es-MX', 'it-IT', 'pt-BR', 'pt-PT', 'nl-NL', 'pl-PL', 'ru-RU', 'uk-UA', 'cs-CZ', 'da-DK', 'fi-FI', 'el-GR', 'hu-HU', 'no-NO', 'ro-RO', 'sk-SK', 'sv-SE', 'tr-TR'].includes(v.language)
|
||||||
|
).map(voice => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={voice.id}
|
||||||
|
style={[
|
||||||
|
styles.voiceItem,
|
||||||
|
selectedVoice.id === voice.id && styles.voiceItemSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedVoice(voice)}
|
||||||
|
>
|
||||||
|
<View style={styles.voiceItemInfo}>
|
||||||
|
<Text style={styles.voiceItemName}>{voice.name}</Text>
|
||||||
|
<Text style={styles.voiceItemLang}>{voice.language}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.voiceItemActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.playButton}
|
||||||
|
onPress={() => testVoice(voice)}
|
||||||
|
>
|
||||||
|
<Feather name="play-circle" size={24} color="#9B59B6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{selectedVoice.id === voice.id && (
|
||||||
|
<Feather name="check-circle" size={24} color={AppColors.success} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Asian Languages Section */}
|
||||||
|
<Text style={styles.voiceSectionTitle}>Asian Languages</Text>
|
||||||
|
{AVAILABLE_VOICES.filter(v =>
|
||||||
|
['zh-CN', 'zh-TW', 'zh-HK', 'ja-JP', 'ko-KR', 'hi-IN', 'th-TH', 'vi-VN', 'id-ID'].includes(v.language)
|
||||||
|
).map(voice => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={voice.id}
|
||||||
|
style={[
|
||||||
|
styles.voiceItem,
|
||||||
|
selectedVoice.id === voice.id && styles.voiceItemSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedVoice(voice)}
|
||||||
|
>
|
||||||
|
<View style={styles.voiceItemInfo}>
|
||||||
|
<Text style={styles.voiceItemName}>{voice.name}</Text>
|
||||||
|
<Text style={styles.voiceItemLang}>{voice.language}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.voiceItemActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.playButton}
|
||||||
|
onPress={() => testVoice(voice)}
|
||||||
|
>
|
||||||
|
<Feather name="play-circle" size={24} color="#9B59B6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{selectedVoice.id === voice.id && (
|
||||||
|
<Feather name="check-circle" size={24} color={AppColors.success} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Middle Eastern Languages Section */}
|
||||||
|
<Text style={styles.voiceSectionTitle}>Middle Eastern</Text>
|
||||||
|
{AVAILABLE_VOICES.filter(v =>
|
||||||
|
['ar-SA', 'he-IL'].includes(v.language)
|
||||||
|
).map(voice => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={voice.id}
|
||||||
|
style={[
|
||||||
|
styles.voiceItem,
|
||||||
|
selectedVoice.id === voice.id && styles.voiceItemSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedVoice(voice)}
|
||||||
|
>
|
||||||
|
<View style={styles.voiceItemInfo}>
|
||||||
|
<Text style={styles.voiceItemName}>{voice.name}</Text>
|
||||||
|
<Text style={styles.voiceItemLang}>{voice.language}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.voiceItemActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.playButton}
|
||||||
|
onPress={() => testVoice(voice)}
|
||||||
|
>
|
||||||
|
<Feather name="play-circle" size={24} color="#9B59B6" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{selectedVoice.id === voice.id && (
|
||||||
|
<Feather name="check-circle" size={24} color={AppColors.success} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Apply Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.applyButton}
|
||||||
|
onPress={() => {
|
||||||
|
setShowVoicePicker(false);
|
||||||
|
Speech.speak(`Voice changed to ${selectedVoice.name}`, {
|
||||||
|
language: selectedVoice.language,
|
||||||
|
voice: selectedVoice.voice,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.applyButtonText}>Apply & Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -596,6 +1166,24 @@ const styles = StyleSheet.create({
|
|||||||
userTimestamp: {
|
userTimestamp: {
|
||||||
color: 'rgba(255,255,255,0.7)',
|
color: 'rgba(255,255,255,0.7)',
|
||||||
},
|
},
|
||||||
|
listeningIndicator: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
backgroundColor: 'rgba(231, 76, 60, 0.1)',
|
||||||
|
marginHorizontal: Spacing.md,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
},
|
||||||
|
listeningText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: '#E74C3C',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: Spacing.sm,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
speakingIndicator: {
|
speakingIndicator: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -613,6 +1201,23 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
marginLeft: Spacing.sm,
|
marginLeft: Spacing.sm,
|
||||||
},
|
},
|
||||||
|
continuousModeIndicator: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: Spacing.sm,
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||||
|
marginHorizontal: Spacing.md,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
},
|
||||||
|
continuousModeText: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
color: '#27AE60',
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: Spacing.sm,
|
||||||
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
@ -632,8 +1237,13 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: AppColors.primary,
|
borderColor: AppColors.primary,
|
||||||
},
|
},
|
||||||
micButtonDisabled: {
|
micButtonActive: {
|
||||||
borderColor: AppColors.textMuted,
|
backgroundColor: '#E74C3C',
|
||||||
|
borderColor: '#E74C3C',
|
||||||
|
},
|
||||||
|
micButtonContinuous: {
|
||||||
|
borderColor: '#27AE60',
|
||||||
|
backgroundColor: 'rgba(39, 174, 96, 0.1)',
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -727,4 +1337,115 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSizes.base,
|
fontSize: FontSizes.base,
|
||||||
color: AppColors.textMuted,
|
color: AppColors.textMuted,
|
||||||
},
|
},
|
||||||
|
// DEV Voice Picker styles
|
||||||
|
headerButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.sm,
|
||||||
|
},
|
||||||
|
voiceSettingsButton: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: BorderRadius.full,
|
||||||
|
backgroundColor: 'rgba(155, 89, 182, 0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(155, 89, 182, 0.3)',
|
||||||
|
},
|
||||||
|
devBadge: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: '#E74C3C',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
currentVoiceInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: Spacing.md,
|
||||||
|
backgroundColor: 'rgba(155, 89, 182, 0.1)',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: AppColors.border,
|
||||||
|
},
|
||||||
|
currentVoiceLabel: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
testVoiceButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#9B59B6',
|
||||||
|
paddingHorizontal: Spacing.md,
|
||||||
|
paddingVertical: Spacing.xs,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
gap: Spacing.xs,
|
||||||
|
},
|
||||||
|
testVoiceButtonText: {
|
||||||
|
color: AppColors.white,
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
voiceList: {
|
||||||
|
flex: 1,
|
||||||
|
padding: Spacing.sm,
|
||||||
|
},
|
||||||
|
voiceSectionTitle: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: Spacing.md,
|
||||||
|
marginBottom: Spacing.sm,
|
||||||
|
marginLeft: Spacing.xs,
|
||||||
|
},
|
||||||
|
voiceItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: Spacing.sm,
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.md,
|
||||||
|
marginBottom: Spacing.xs,
|
||||||
|
},
|
||||||
|
voiceItemSelected: {
|
||||||
|
backgroundColor: 'rgba(155, 89, 182, 0.15)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#9B59B6',
|
||||||
|
},
|
||||||
|
voiceItemInfo: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
voiceItemName: {
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
voiceItemLang: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
voiceItemActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.sm,
|
||||||
|
},
|
||||||
|
playButton: {
|
||||||
|
padding: Spacing.xs,
|
||||||
|
},
|
||||||
|
applyButton: {
|
||||||
|
backgroundColor: '#9B59B6',
|
||||||
|
margin: Spacing.md,
|
||||||
|
padding: Spacing.md,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
applyButtonText: {
|
||||||
|
color: AppColors.white,
|
||||||
|
fontSize: FontSizes.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -13,6 +13,8 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"expo": "~54.0.29",
|
"expo": "~54.0.29",
|
||||||
|
"expo-audio": "~1.1.1",
|
||||||
|
"expo-av": "~16.0.8",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.12",
|
||||||
"expo-crypto": "~15.0.8",
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
@ -6571,6 +6573,35 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-audio": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"expo-asset": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-av": {
|
||||||
|
"version": "16.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz",
|
||||||
|
"integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*",
|
||||||
|
"react-native-web": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-web": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.12",
|
"version": "18.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"expo": "~54.0.29",
|
"expo": "~54.0.29",
|
||||||
|
"expo-audio": "~1.1.1",
|
||||||
|
"expo-av": "~16.0.8",
|
||||||
"expo-constants": "~18.0.12",
|
"expo-constants": "~18.0.12",
|
||||||
"expo-crypto": "~15.0.8",
|
"expo-crypto": "~15.0.8",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user