WellNuo/app/(tabs)/voice.tsx
Sergei 7cb07c09ce Major UI/UX updates: Voice, Subscription, Beneficiaries, Profile
- Voice tab: simplified interface, voice picker improvements
- Subscription: Stripe integration, purchase flow updates
- Beneficiaries: dashboard, sharing, improved management
- Profile: drawer, edit, help, privacy sections
- Theme: expanded constants, new colors
- New components: MockDashboard, ProfileDrawer, Toast
- Backend: Stripe routes additions
- Auth: activate, add-loved-one, purchase screens
2025-12-29 15:36:44 -08:00

1327 lines
42 KiB
TypeScript

import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Alert,
ActivityIndicator,
Modal,
ScrollView,
Animated,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import * as SecureStore from 'expo-secure-store';
import * as Speech from 'expo-speech';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { api } from '@/services/api';
import SherpaTTS from '@/services/sherpaTTS';
import {
AppColors,
BorderRadius,
FontSizes,
Spacing,
FontWeights,
Shadows,
} from '@/constants/theme';
import type { Message, Beneficiary } from '@/types';
// Try to import speech recognition if available (not available in Expo Go)
let ExpoSpeechRecognitionModule: any = null;
let SPEECH_RECOGNITION_AVAILABLE = false;
try {
const speechRecognition = require('expo-speech-recognition');
ExpoSpeechRecognitionModule = speechRecognition.ExpoSpeechRecognitionModule;
SPEECH_RECOGNITION_AVAILABLE = true;
} catch (e) {
console.log('[Voice] expo-speech-recognition not available in Expo Go');
}
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: 'ru-RU', name: 'Russian', language: 'ru-RU' },
// Asian languages
{ id: 'zh-CN', name: 'Chinese (Mandarin)', language: 'zh-CN' },
{ id: 'ja-JP', name: 'Japanese', language: 'ja-JP' },
{ id: 'ko-KR', name: 'Korean', language: 'ko-KR' },
];
interface ActivityData {
name: string;
rooms: Array<{
name: string;
data: Array<{
title: string;
events: number;
hours: number;
}>;
}>;
}
interface ActivitiesResponse {
alert_text: string;
chart_data: ActivityData[];
}
interface VoiceAskResponse {
ok: boolean;
response: {
Command: string;
body: string;
name?: string;
reflected?: string;
language?: string;
time?: number;
};
status: string;
}
export default function VoiceAIScreen() {
const { currentBeneficiary, setCurrentBeneficiary } = useBeneficiary();
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'assistant',
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(),
},
]);
const [input, setInput] = useState('');
const [isSending, setIsSending] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isListening, setIsListening] = useState(false);
const [recognizedText, setRecognizedText] = useState('');
const [showBeneficiaryPicker, setShowBeneficiaryPicker] = useState(false);
const [isContinuousMode, setIsContinuousMode] = useState(false);
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
const [selectedVoice, setSelectedVoice] = useState<VoiceOption>(AVAILABLE_VOICES[0]);
const [showVoicePicker, setShowVoicePicker] = useState(false);
const [sherpaTTSReady, setSherpaTTSReady] = useState(false);
const [useNeuralTTS, setUseNeuralTTS] = useState(true);
const flatListRef = useRef<FlatList>(null);
const lastSendTimeRef = useRef<number>(0);
const pulseAnim = useRef(new Animated.Value(1)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
const SEND_COOLDOWN_MS = 1000;
// Speech recognition event handlers
useEffect(() => {
if (!SPEECH_RECOGNITION_AVAILABLE || !ExpoSpeechRecognitionModule) {
return;
}
const subscriptions: any[] = [];
try {
if (ExpoSpeechRecognitionModule.addListener) {
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('start', () => {
setIsListening(true);
setRecognizedText('');
})
);
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('end', () => {
setIsListening(false);
})
);
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('result', (event: any) => {
const transcript = event.results?.[0]?.transcript || '';
setRecognizedText(transcript);
if (event.isFinal && transcript.trim()) {
setInput(transcript);
setTimeout(() => {
handleSendWithText(transcript);
}, 300);
}
})
);
subscriptions.push(
ExpoSpeechRecognitionModule.addListener('error', (event: any) => {
setIsListening(false);
if (event.error !== 'no-speech') {
Alert.alert('Voice Error', event.message || 'Could not recognize speech.');
}
})
);
}
} catch (e) {
console.log('[Voice] Could not set up speech recognition listeners:', e);
}
return () => {
subscriptions.forEach((sub) => sub?.remove?.());
};
}, []);
// Load beneficiaries and initialize TTS
useEffect(() => {
loadBeneficiaries();
const initTTS = async () => {
try {
const success = await SherpaTTS.initialize();
setSherpaTTSReady(success);
if (!success) {
setUseNeuralTTS(false);
}
} catch (error) {
setUseNeuralTTS(false);
}
};
initTTS();
return () => {
Speech.stop();
SherpaTTS.stop();
SherpaTTS.deinitialize();
};
}, []);
// Modal animation
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: showBeneficiaryPicker || showVoicePicker ? 1 : 0,
duration: 250,
useNativeDriver: true,
}).start();
}, [showBeneficiaryPicker, showVoicePicker]);
const loadBeneficiaries = async () => {
const response = await api.getAllBeneficiaries();
if (response.ok && response.data) {
setBeneficiaries(response.data);
}
};
// Pulse animation for speaking
useEffect(() => {
if (isSpeaking) {
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.3,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true,
}),
])
);
pulse.start();
return () => pulse.stop();
}
}, [isSpeaking]);
// Fetch activity data
const getActivityContext = async (token: string, userName: string, deploymentId: string): Promise<string> => {
try {
const response = await fetch(OLD_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
function: 'activities_report_details',
user_name: userName,
token: token,
deployment_id: deploymentId,
filter: '0',
}).toString(),
});
const data: ActivitiesResponse = await response.json();
if (!data.chart_data || data.chart_data.length === 0) {
return '';
}
const weeklyData = data.chart_data.find(d => d.name === 'Weekly');
if (!weeklyData) return '';
const lines: string[] = [];
lines.push(`Alert status: ${data.alert_text || 'No alert'}`);
const todayStats: string[] = [];
for (const room of weeklyData.rooms) {
const todayData = room.data[room.data.length - 1];
if (todayData && todayData.hours > 0) {
todayStats.push(`${room.name}: ${todayData.hours.toFixed(1)} hours (${todayData.events} events)`);
}
}
if (todayStats.length > 0) {
lines.push(`Today's activity: ${todayStats.join(', ')}`);
}
return lines.join('. ');
} catch (error) {
return '';
}
};
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();
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}`);
return lines.join('. ');
} catch (error) {
return '';
}
};
const sendToVoiceAsk = async (question: string): Promise<string> => {
const token = await SecureStore.getItemAsync('accessToken');
const userName = await SecureStore.getItemAsync('userName');
if (!token || !userName) throw new Error('Please log in to use voice assistant');
if (!currentBeneficiary?.id) throw new Error('Please select a beneficiary first');
const beneficiaryName = currentBeneficiary.name || 'the patient';
const deploymentId = currentBeneficiary.id.toString();
let activityContext = await getActivityContext(token, userName, deploymentId);
if (!activityContext) {
activityContext = await getDashboardContext(token, userName, deploymentId);
}
let enhancedQuestion: string;
if (activityContext) {
enhancedQuestion = `You are a caring assistant helping monitor ${beneficiaryName}'s wellbeing.\n\nHere is the current data about ${beneficiaryName}:\n${activityContext}\n\nBased on this data, please answer: ${question}`;
} else {
enhancedQuestion = `You are a caring assistant helping monitor ${beneficiaryName}'s wellbeing. Please answer: ${question}`;
}
const response = await fetch(OLD_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
function: 'voice_ask',
clientId: '001',
user_name: userName,
token: token,
question: enhancedQuestion,
deployment_id: deploymentId,
context: activityContext || '',
}).toString(),
});
const data: VoiceAskResponse = await response.json();
if (data.ok && data.response?.body) {
return data.response.body;
} else if (data.status === '401 Unauthorized') {
throw new Error('Session expired. Please log in again.');
} else {
throw new Error('Could not get response from voice assistant');
}
};
const speakResponse = async (text: string, autoListenAfter: boolean = false) => {
setIsSpeaking(true);
const onSpeechComplete = () => {
setIsSpeaking(false);
if (autoListenAfter && isContinuousMode && currentBeneficiary?.id) {
setTimeout(() => startListeningInternal(), 500);
}
};
try {
if (useNeuralTTS && sherpaTTSReady && selectedVoice.language.startsWith('en')) {
await SherpaTTS.speak(text, {
speed: 1.0,
onDone: onSpeechComplete,
onError: () => speakWithSystemTTS(text, onSpeechComplete),
});
} else {
speakWithSystemTTS(text, onSpeechComplete);
}
} catch (error) {
setIsSpeaking(false);
}
};
const speakWithSystemTTS = (text: string, onDone: () => void) => {
const speechOptions: Speech.SpeechOptions = {
language: selectedVoice.language,
pitch: 1.0,
rate: 0.9,
onDone,
onError: () => setIsSpeaking(false),
};
if (selectedVoice.voice) speechOptions.voice = selectedVoice.voice;
Speech.speak(text, speechOptions);
};
const testVoice = (voice: VoiceOption) => {
Speech.stop();
Speech.speak('Hello! I am Julia, your voice assistant.', {
language: voice.language,
voice: voice.voice,
});
};
const handleSend = useCallback(async () => {
const trimmedInput = input.trim();
if (!trimmedInput || isSending) return;
if (!currentBeneficiary?.id) {
Alert.alert('Select Beneficiary', 'Please select a beneficiary first.',
[{ text: 'Select', onPress: () => setShowBeneficiaryPicker(true) }, { text: 'Cancel' }]
);
return;
}
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('');
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]);
await speakResponse(aiResponse);
} 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'}`,
timestamp: new Date(),
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsSending(false);
}
}, [input, isSending, currentBeneficiary]);
const selectBeneficiary = (beneficiary: Beneficiary) => {
setCurrentBeneficiary(beneficiary);
setShowBeneficiaryPicker(false);
const welcomeMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
content: `Ready to answer questions about ${beneficiary.name}. ${beneficiary.wellness_descriptor ? `Status: ${beneficiary.wellness_descriptor}.` : ''} Ask me anything!`,
timestamp: new Date(),
};
setMessages(prev => [...prev, welcomeMessage]);
speakResponse(`Ready to answer questions about ${beneficiary.name}`);
};
const stopSpeaking = async () => {
Speech.stop();
SherpaTTS.stop();
setIsSpeaking(false);
setIsContinuousMode(false);
};
const startListeningInternal = () => {
if (!SPEECH_RECOGNITION_AVAILABLE || !ExpoSpeechRecognitionModule) return;
if (isSending || isSpeaking) return;
if (!currentBeneficiary?.id) return;
Speech.stop();
setIsSpeaking(false);
ExpoSpeechRecognitionModule.start({
lang: 'en-US',
interimResults: true,
maxAlternatives: 1,
continuous: false,
});
};
const startListening = async () => {
if (!SPEECH_RECOGNITION_AVAILABLE || !ExpoSpeechRecognitionModule) {
Alert.alert('Voice Not Available', 'Voice recognition requires a native build.');
return;
}
if (isSending || isSpeaking) return;
if (!currentBeneficiary?.id) {
Alert.alert('Select Beneficiary', 'Please select a beneficiary first.',
[{ text: 'Select', onPress: () => setShowBeneficiaryPicker(true) }, { text: 'Cancel' }]
);
return;
}
const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
if (!result.granted) {
Alert.alert('Microphone Permission Required', 'Please grant microphone permission.');
return;
}
setIsContinuousMode(true);
Speech.stop();
setIsSpeaking(false);
ExpoSpeechRecognitionModule.start({
lang: 'en-US',
interimResults: true,
maxAlternatives: 1,
continuous: false,
});
};
const stopListening = () => {
if (ExpoSpeechRecognitionModule) {
ExpoSpeechRecognitionModule.stop();
}
setIsListening(false);
setIsContinuousMode(false);
};
const handleSendWithText = async (text: string) => {
const trimmedInput = text.trim();
if (!trimmedInput || isSending) return;
if (!currentBeneficiary?.id) return;
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]);
await speakResponse(aiResponse, true);
} catch (error) {
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `Sorry, an error occurred: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: new Date(),
};
setMessages(prev => [...prev, errorMessage]);
if (isContinuousMode && currentBeneficiary?.id) {
setTimeout(() => startListeningInternal(), 500);
}
} finally {
setIsSending(false);
}
};
const renderMessage = ({ item }: { item: Message }) => {
const isUser = item.role === 'user';
return (
<View style={[styles.messageRow, isUser && styles.userMessageRow]}>
{!isUser && (
<View style={styles.avatarContainer}>
<Ionicons name="mic" size={16} color={AppColors.white} />
</View>
)}
<View style={[styles.messageBubble, isUser ? styles.userBubble : styles.assistantBubble]}>
<Text style={[styles.messageText, isUser && styles.userMessageText]}>
{item.content}
</Text>
<Text style={[styles.timestamp, isUser && styles.userTimestamp]}>
{item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
</View>
</View>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.headerAvatar}>
<Ionicons name="mic" size={20} color={AppColors.white} />
</View>
<View>
<Text style={styles.headerTitle}>Julia AI</Text>
<Text style={styles.headerSubtitle}>
{isSending ? 'Thinking...' : isListening ? 'Listening...' : isSpeaking ? 'Speaking...' :
currentBeneficiary ? `Monitoring ${currentBeneficiary.name}` : 'Select a beneficiary'}
</Text>
</View>
</View>
<View style={styles.headerRight}>
{DEV_MODE && (
<TouchableOpacity style={styles.voiceSettingsButton} onPress={() => setShowVoicePicker(true)}>
<Ionicons name="settings-outline" size={18} color={AppColors.accent} />
</TouchableOpacity>
)}
<TouchableOpacity style={styles.beneficiaryButton} onPress={() => setShowBeneficiaryPicker(true)}>
<Ionicons name="people-outline" size={18} color={AppColors.primary} />
<Text style={styles.beneficiaryButtonText}>
{currentBeneficiary?.name?.split(' ')[0] || 'Select'}
</Text>
</TouchableOpacity>
</View>
</View>
{/* Messages */}
<KeyboardAvoidingView
style={styles.chatContainer}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messagesList}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
/>
{/* Listening indicator */}
{isListening && (
<TouchableOpacity style={styles.listeningIndicator} onPress={stopListening}>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<Ionicons name="mic" size={20} color={AppColors.error} />
</Animated.View>
<Text style={styles.listeningText}>
{recognizedText || 'Listening... tap to stop'}
</Text>
</TouchableOpacity>
)}
{/* Speaking indicator */}
{isSpeaking && !isListening && (
<TouchableOpacity style={styles.speakingIndicator} onPress={stopSpeaking}>
<Ionicons name="volume-high" size={20} color={AppColors.accent} />
<Text style={styles.speakingText}>
{isContinuousMode ? 'Live mode - Speaking...' : 'Speaking...'} tap to stop
</Text>
</TouchableOpacity>
)}
{/* Continuous mode indicator */}
{isContinuousMode && !isListening && !isSpeaking && !isSending && (
<TouchableOpacity style={styles.continuousModeIndicator} onPress={() => setIsContinuousMode(false)}>
<Ionicons name="radio" size={20} color={AppColors.success} />
<Text style={styles.continuousModeText}>Live chat active - tap to stop</Text>
</TouchableOpacity>
)}
{/* Input */}
<View style={styles.inputContainer}>
<TouchableOpacity
style={[
styles.micButton,
isListening && styles.micButtonActive,
isContinuousMode && !isListening && styles.micButtonContinuous
]}
onPress={isListening ? stopListening : startListening}
disabled={isSending}
>
<Ionicons
name="mic"
size={24}
color={isListening ? AppColors.white : (isContinuousMode ? AppColors.success : AppColors.primary)}
/>
</TouchableOpacity>
<View style={styles.inputWrapper}>
<TextInput
style={styles.input}
placeholder="Type your question..."
placeholderTextColor={AppColors.textMuted}
value={input}
onChangeText={setInput}
multiline
maxLength={1000}
editable={!isSending}
onSubmitEditing={handleSend}
/>
</View>
<TouchableOpacity
style={[styles.sendButton, (!input.trim() || isSending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!input.trim() || isSending}
>
{isSending ? (
<ActivityIndicator size="small" color={AppColors.white} />
) : (
<Ionicons name="send" size={20} color={input.trim() ? AppColors.white : AppColors.textMuted} />
)}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
{/* Beneficiary Picker Modal */}
<Modal visible={showBeneficiaryPicker} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<Animated.View style={[styles.modalBackdrop, { opacity: fadeAnim }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} onPress={() => setShowBeneficiaryPicker(false)} />
</Animated.View>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalHeaderContent}>
<View style={styles.modalIconContainer}>
<Ionicons name="people" size={22} color={AppColors.primary} />
</View>
<Text style={styles.modalTitle}>Select Beneficiary</Text>
</View>
<TouchableOpacity style={styles.modalCloseButton} onPress={() => setShowBeneficiaryPicker(false)}>
<Ionicons name="close" size={22} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.beneficiaryList}>
{beneficiaries.length === 0 ? (
<View style={styles.emptyState}>
<ActivityIndicator size="large" color={AppColors.primary} />
<Text style={styles.emptyStateText}>Loading beneficiaries...</Text>
</View>
) : (
beneficiaries.map(beneficiary => (
<TouchableOpacity
key={beneficiary.id}
style={[styles.beneficiaryItem, currentBeneficiary?.id === beneficiary.id && styles.beneficiaryItemSelected]}
onPress={() => selectBeneficiary(beneficiary)}
>
<View style={styles.beneficiaryAvatar}>
<Text style={styles.beneficiaryAvatarText}>{beneficiary.name.charAt(0).toUpperCase()}</Text>
</View>
<View style={styles.beneficiaryInfo}>
<Text style={styles.beneficiaryName}>{beneficiary.name}</Text>
<Text style={styles.beneficiaryStatus}>
{beneficiary.wellness_descriptor || beneficiary.last_location || 'No data'}
</Text>
</View>
<View style={[styles.statusDot, { backgroundColor: beneficiary.status === 'online' ? AppColors.online : AppColors.offline }]} />
</TouchableOpacity>
))
)}
</ScrollView>
</View>
</View>
</Modal>
{/* Voice Picker Modal (DEV) */}
{DEV_MODE && (
<Modal visible={showVoicePicker} animationType="slide" transparent>
<View style={styles.modalOverlay}>
<Animated.View style={[styles.modalBackdrop, { opacity: fadeAnim }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} onPress={() => setShowVoicePicker(false)} />
</Animated.View>
<View style={[styles.modalContent, { maxHeight: '80%' }]}>
<View style={styles.modalHeader}>
<View style={styles.modalHeaderContent}>
<View style={[styles.modalIconContainer, { backgroundColor: AppColors.accentLight }]}>
<Ionicons name="settings" size={22} color={AppColors.accent} />
</View>
<View>
<Text style={styles.modalTitle}>Voice Settings</Text>
<Text style={styles.devBadge}>DEV ONLY</Text>
</View>
</View>
<TouchableOpacity style={styles.modalCloseButton} onPress={() => setShowVoicePicker(false)}>
<Ionicons name="close" size={22} color={AppColors.textSecondary} />
</TouchableOpacity>
</View>
<View style={styles.currentVoiceInfo}>
<Text style={styles.currentVoiceLabel}>Current: {selectedVoice.name}</Text>
<TouchableOpacity style={styles.testVoiceButton} onPress={() => testVoice(selectedVoice)}>
<Ionicons name="play" size={16} color={AppColors.white} />
<Text style={styles.testVoiceButtonText}>Test</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.voiceList} showsVerticalScrollIndicator={false}>
<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)}>
<Ionicons name="play-circle" size={24} color={AppColors.accent} />
</TouchableOpacity>
{selectedVoice.id === voice.id && (
<Ionicons name="checkmark-circle" size={24} color={AppColors.success} />
)}
</View>
</TouchableOpacity>
))}
<Text style={styles.voiceSectionTitle}>Other Languages</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)}>
<Ionicons name="play-circle" size={24} color={AppColors.accent} />
</TouchableOpacity>
{selectedVoice.id === voice.id && (
<Ionicons name="checkmark-circle" size={24} color={AppColors.success} />
)}
</View>
</TouchableOpacity>
))}
</ScrollView>
<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>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
// Header
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
headerAvatar: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.accent,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
headerSubtitle: {
fontSize: FontSizes.sm,
color: AppColors.success,
fontWeight: FontWeights.medium,
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
voiceSettingsButton: {
width: 36,
height: 36,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.accentLight,
justifyContent: 'center',
alignItems: 'center',
},
beneficiaryButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.primarySubtle,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
},
beneficiaryButtonText: {
fontSize: FontSizes.sm,
color: AppColors.primary,
fontWeight: FontWeights.medium,
},
// Chat
chatContainer: {
flex: 1,
},
messagesList: {
padding: Spacing.lg,
paddingBottom: Spacing.xl,
},
messageRow: {
flexDirection: 'row',
marginBottom: Spacing.md,
alignItems: 'flex-end',
},
userMessageRow: {
justifyContent: 'flex-end',
},
avatarContainer: {
width: 32,
height: 32,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.accent,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.sm,
},
messageBubble: {
maxWidth: '75%',
padding: Spacing.md,
borderRadius: BorderRadius.xl,
},
userBubble: {
backgroundColor: AppColors.primary,
borderBottomRightRadius: BorderRadius.xs,
...Shadows.sm,
},
assistantBubble: {
backgroundColor: AppColors.surface,
borderBottomLeftRadius: BorderRadius.xs,
...Shadows.xs,
},
messageText: {
fontSize: FontSizes.base,
lineHeight: 22,
color: AppColors.textPrimary,
},
userMessageText: {
color: AppColors.white,
},
timestamp: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: Spacing.xs,
alignSelf: 'flex-end',
},
userTimestamp: {
color: 'rgba(255,255,255,0.7)',
},
// Indicators
listeningIndicator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.errorLight,
marginHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.sm,
},
listeningText: {
fontSize: FontSizes.sm,
color: AppColors.error,
fontWeight: FontWeights.medium,
marginLeft: Spacing.sm,
flex: 1,
},
speakingIndicator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.accentLight,
marginHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.sm,
},
speakingText: {
fontSize: FontSizes.sm,
color: AppColors.accent,
fontWeight: FontWeights.medium,
marginLeft: Spacing.sm,
},
continuousModeIndicator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.successLight,
marginHorizontal: Spacing.lg,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.sm,
},
continuousModeText: {
fontSize: FontSizes.sm,
color: AppColors.success,
fontWeight: FontWeights.medium,
marginLeft: Spacing.sm,
},
// Input
inputContainer: {
flexDirection: 'row',
alignItems: 'flex-end',
padding: Spacing.md,
paddingHorizontal: Spacing.lg,
backgroundColor: AppColors.surface,
borderTopWidth: 1,
borderTopColor: AppColors.border,
gap: Spacing.sm,
},
micButton: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primarySubtle,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: AppColors.primary,
},
micButtonActive: {
backgroundColor: AppColors.error,
borderColor: AppColors.error,
},
micButtonContinuous: {
borderColor: AppColors.success,
backgroundColor: AppColors.successLight,
},
inputWrapper: {
flex: 1,
backgroundColor: AppColors.surfaceSecondary,
borderRadius: BorderRadius.xl,
borderWidth: 1.5,
borderColor: AppColors.borderLight,
},
input: {
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
maxHeight: 100,
},
sendButton: {
width: 48,
height: 48,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primary,
justifyContent: 'center',
alignItems: 'center',
...Shadows.primary,
},
sendButtonDisabled: {
backgroundColor: AppColors.surfaceSecondary,
shadowOpacity: 0,
},
// Modal
modalOverlay: {
flex: 1,
justifyContent: 'flex-end',
},
modalBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: AppColors.overlay,
},
modalContent: {
backgroundColor: AppColors.background,
borderTopLeftRadius: BorderRadius['2xl'],
borderTopRightRadius: BorderRadius['2xl'],
maxHeight: '70%',
...Shadows.xl,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.lg,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
modalHeaderContent: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
modalIconContainer: {
width: 40,
height: 40,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
},
modalTitle: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
modalCloseButton: {
width: 36,
height: 36,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
devBadge: {
fontSize: FontSizes.xs,
color: AppColors.error,
fontWeight: FontWeights.semibold,
},
beneficiaryList: {
padding: Spacing.lg,
},
beneficiaryItem: {
flexDirection: 'row',
alignItems: 'center',
padding: Spacing.md,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
marginBottom: Spacing.sm,
...Shadows.xs,
},
beneficiaryItemSelected: {
backgroundColor: AppColors.primarySubtle,
borderWidth: 2,
borderColor: AppColors.primary,
},
beneficiaryAvatar: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
backgroundColor: AppColors.primaryLighter,
justifyContent: 'center',
alignItems: 'center',
marginRight: Spacing.md,
},
beneficiaryAvatarText: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
beneficiaryInfo: {
flex: 1,
},
beneficiaryName: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
beneficiaryStatus: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: 2,
},
statusDot: {
width: 12,
height: 12,
borderRadius: 6,
},
emptyState: {
alignItems: 'center',
padding: Spacing.xl,
},
emptyStateText: {
marginTop: Spacing.md,
fontSize: FontSizes.base,
color: AppColors.textMuted,
},
// Voice Picker
currentVoiceInfo: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: Spacing.md,
paddingHorizontal: Spacing.lg,
backgroundColor: AppColors.accentLight,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
currentVoiceLabel: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
fontWeight: FontWeights.medium,
flex: 1,
},
testVoiceButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.accent,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
borderRadius: BorderRadius.lg,
gap: Spacing.xs,
},
testVoiceButtonText: {
color: AppColors.white,
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
},
voiceList: {
padding: Spacing.lg,
},
voiceSectionTitle: {
fontSize: FontSizes.xs,
fontWeight: FontWeights.semibold,
color: AppColors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginTop: Spacing.md,
marginBottom: Spacing.sm,
},
voiceItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: Spacing.md,
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
marginBottom: Spacing.sm,
...Shadows.xs,
},
voiceItemSelected: {
backgroundColor: AppColors.accentLight,
borderWidth: 1,
borderColor: AppColors.accent,
},
voiceItemInfo: {
flex: 1,
},
voiceItemName: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
fontWeight: FontWeights.medium,
},
voiceItemLang: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: 2,
},
voiceItemActions: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
},
playButton: {
padding: Spacing.xs,
},
applyButton: {
backgroundColor: AppColors.accent,
margin: Spacing.lg,
padding: Spacing.md,
borderRadius: BorderRadius.lg,
alignItems: 'center',
...Shadows.primary,
},
applyButtonText: {
color: AppColors.white,
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
},
});