- Switch Android STT from on-device to cloud recognition for better accuracy - Add lastMessageWasVoiceRef to prevent TTS for text-typed messages - Stop voice session and clear chat when changing Deployment or Voice API - Ensures clean state when switching between beneficiaries/models
646 lines
19 KiB
TypeScript
646 lines
19 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
Alert,
|
|
TextInput,
|
|
Modal,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { router } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { useVoice } from '@/contexts/VoiceContext';
|
|
import { useChat } from '@/contexts/ChatContext';
|
|
import { api } from '@/services/api';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
|
|
interface MenuItemProps {
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
iconColor?: string;
|
|
iconBgColor?: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
onPress?: () => void;
|
|
showChevron?: boolean;
|
|
}
|
|
|
|
function MenuItem({
|
|
icon,
|
|
iconColor = AppColors.primary,
|
|
iconBgColor = '#DBEAFE',
|
|
title,
|
|
subtitle,
|
|
onPress,
|
|
showChevron = true,
|
|
}: MenuItemProps) {
|
|
return (
|
|
<TouchableOpacity style={styles.menuItem} onPress={onPress}>
|
|
<View style={[styles.menuIconContainer, { backgroundColor: iconBgColor }]}>
|
|
<Ionicons name={icon} size={20} color={iconColor} />
|
|
</View>
|
|
<View style={styles.menuTextContainer}>
|
|
<Text style={styles.menuTitle}>{title}</Text>
|
|
{subtitle && <Text style={styles.menuSubtitle}>{subtitle}</Text>}
|
|
</View>
|
|
{showChevron && (
|
|
<Ionicons name="chevron-forward" size={20} color={AppColors.textMuted} />
|
|
)}
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
export default function ProfileScreen() {
|
|
const { user, logout } = useAuth();
|
|
const { updateVoiceApiType, stopSession } = useVoice();
|
|
const { clearMessages } = useChat();
|
|
const [deploymentId, setDeploymentId] = useState<string>('');
|
|
const [deploymentName, setDeploymentName] = useState<string>('');
|
|
const [showDeploymentModal, setShowDeploymentModal] = useState(false);
|
|
const [tempDeploymentId, setTempDeploymentId] = useState('');
|
|
const [isValidating, setIsValidating] = useState(false);
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
|
|
// Voice API Type state
|
|
const [voiceApiType, setVoiceApiType] = useState<'voice_ask' | 'ask_wellnuo_ai'>('ask_wellnuo_ai');
|
|
const [showVoiceApiModal, setShowVoiceApiModal] = useState(false);
|
|
const [tempVoiceApiType, setTempVoiceApiType] = useState<'voice_ask' | 'ask_wellnuo_ai'>('ask_wellnuo_ai');
|
|
|
|
// Load saved deployment ID or auto-populate from first available
|
|
useEffect(() => {
|
|
const loadDeploymentId = async () => {
|
|
const saved = await api.getDeploymentId();
|
|
if (saved) {
|
|
// Use saved deployment ID
|
|
setDeploymentId(saved);
|
|
// Validate to get the deployment name
|
|
const result = await api.validateDeploymentId(saved);
|
|
if (result.ok && result.data?.valid && result.data.name) {
|
|
setDeploymentName(result.data.name);
|
|
}
|
|
} else {
|
|
// No saved ID - auto-populate from first available deployment
|
|
const firstResult = await api.getFirstDeploymentId();
|
|
if (firstResult.ok && firstResult.data) {
|
|
setDeploymentId(firstResult.data.deploymentId);
|
|
setDeploymentName(firstResult.data.name);
|
|
// Also save it so it persists
|
|
await api.setDeploymentId(firstResult.data.deploymentId);
|
|
}
|
|
}
|
|
};
|
|
loadDeploymentId();
|
|
}, []);
|
|
|
|
// Load saved Voice API type
|
|
useEffect(() => {
|
|
const loadVoiceApiType = async () => {
|
|
const saved = await api.getVoiceApiType();
|
|
setVoiceApiType(saved);
|
|
};
|
|
loadVoiceApiType();
|
|
}, []);
|
|
|
|
const openDeploymentModal = useCallback(() => {
|
|
setTempDeploymentId(deploymentId);
|
|
setValidationError(null);
|
|
setShowDeploymentModal(true);
|
|
}, [deploymentId]);
|
|
|
|
const openVoiceApiModal = useCallback(() => {
|
|
setTempVoiceApiType(voiceApiType);
|
|
setShowVoiceApiModal(true);
|
|
}, [voiceApiType]);
|
|
|
|
const saveDeploymentId = useCallback(async () => {
|
|
const trimmed = tempDeploymentId.trim();
|
|
setValidationError(null);
|
|
|
|
if (trimmed) {
|
|
setIsValidating(true);
|
|
try {
|
|
const result = await api.validateDeploymentId(trimmed);
|
|
if (result.ok && result.data?.valid) {
|
|
// ALWAYS stop voice session when deployment changes
|
|
console.log('[Profile] Stopping voice session and clearing chat before deployment change');
|
|
stopSession();
|
|
|
|
// Clear chat history when deployment changes
|
|
clearMessages({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: `Hello! I'm Julia, your AI wellness companion.${result.data.name ? `\n\nI'm here to help you monitor ${result.data.name}.` : ''}\n\nType a message below to chat with me.`,
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
await api.setDeploymentId(trimmed);
|
|
if (result.data.name) {
|
|
await api.setDeploymentName(result.data.name);
|
|
}
|
|
setDeploymentId(trimmed);
|
|
setDeploymentName(result.data.name || '');
|
|
setShowDeploymentModal(false);
|
|
} else if (result.ok && !result.data?.valid) {
|
|
setValidationError('Invalid Deployment ID. Please check and try again.');
|
|
} else {
|
|
setValidationError(result.error?.message || 'Failed to validate Deployment ID');
|
|
}
|
|
} catch {
|
|
setValidationError('Network error. Please try again.');
|
|
} finally {
|
|
setIsValidating(false);
|
|
}
|
|
} else {
|
|
// ALWAYS stop voice session when deployment is cleared
|
|
console.log('[Profile] Stopping voice session and clearing chat before clearing deployment');
|
|
stopSession();
|
|
|
|
// Clear chat history when deployment is cleared
|
|
clearMessages({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: "Hello! I'm Julia, your AI wellness companion.\n\nType a message below to chat with me.",
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
await api.clearDeploymentId();
|
|
setDeploymentId('');
|
|
setDeploymentName('');
|
|
setShowDeploymentModal(false);
|
|
}
|
|
}, [tempDeploymentId, stopSession, clearMessages]);
|
|
|
|
const saveVoiceApiType = useCallback(async () => {
|
|
// ALWAYS stop voice session when API type changes
|
|
console.log('[Profile] Stopping voice session and clearing chat before API type change');
|
|
stopSession();
|
|
|
|
// Clear chat history when Voice API changes
|
|
clearMessages({
|
|
id: '1',
|
|
role: 'assistant',
|
|
content: "Hello! I'm Julia, your AI wellness companion.\n\nType a message below to chat with me.",
|
|
timestamp: new Date(),
|
|
});
|
|
|
|
await api.setVoiceApiType(tempVoiceApiType);
|
|
setVoiceApiType(tempVoiceApiType);
|
|
updateVoiceApiType(tempVoiceApiType);
|
|
setShowVoiceApiModal(false);
|
|
}, [tempVoiceApiType, updateVoiceApiType, stopSession, clearMessages]);
|
|
|
|
const openTerms = () => {
|
|
router.push('/terms');
|
|
};
|
|
|
|
const openPrivacy = () => {
|
|
router.push('/privacy');
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
Alert.alert(
|
|
'Logout',
|
|
'Are you sure you want to logout?',
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Logout',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
await logout();
|
|
router.replace('/(auth)/login');
|
|
},
|
|
},
|
|
],
|
|
{ cancelable: true }
|
|
);
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Text style={styles.headerTitle}>Profile</Text>
|
|
</View>
|
|
|
|
{/* User Info */}
|
|
<View style={styles.userCard}>
|
|
<View style={styles.avatarContainer}>
|
|
<Text style={styles.avatarText}>
|
|
{user?.user_name?.charAt(0).toUpperCase() || 'U'}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.userInfo}>
|
|
<Text style={styles.userName}>{user?.user_name || 'User'}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Settings */}
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Settings</Text>
|
|
<View style={styles.menuCard}>
|
|
<MenuItem
|
|
icon="server-outline"
|
|
title="Deployment"
|
|
subtitle={deploymentId ? (deploymentName || `ID: ${deploymentId}`) : 'Auto'}
|
|
onPress={openDeploymentModal}
|
|
/>
|
|
<View style={styles.menuDivider} />
|
|
<MenuItem
|
|
icon="radio-outline"
|
|
iconColor="#9333EA"
|
|
iconBgColor="#F3E8FF"
|
|
title="Voice API"
|
|
subtitle={voiceApiType === 'voice_ask' ? 'voice_ask' : 'ask_wellnuo_ai (LLaMA)'}
|
|
onPress={openVoiceApiModal}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Legal - Required for App Store */}
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Legal</Text>
|
|
<View style={styles.menuCard}>
|
|
<MenuItem
|
|
icon="document-text-outline"
|
|
title="Terms of Service"
|
|
onPress={openTerms}
|
|
/>
|
|
<View style={styles.menuDivider} />
|
|
<MenuItem
|
|
icon="shield-outline"
|
|
title="Privacy Policy"
|
|
onPress={openPrivacy}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Logout Button */}
|
|
<View style={styles.section}>
|
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
|
<Ionicons name="log-out-outline" size={20} color={AppColors.error} />
|
|
<Text style={styles.logoutText}>Logout</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Version */}
|
|
<Text style={styles.version}>WellNuo v1.0.0</Text>
|
|
</ScrollView>
|
|
|
|
{/* Deployment ID Modal */}
|
|
<Modal
|
|
visible={showDeploymentModal}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={() => setShowDeploymentModal(false)}
|
|
>
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
style={styles.modalOverlay}
|
|
>
|
|
<View style={styles.modalContent}>
|
|
<Text style={styles.modalTitle}>Deployment ID</Text>
|
|
<Text style={styles.modalDescription}>
|
|
Enter the deployment ID to connect to a specific device. Leave empty for automatic detection.
|
|
</Text>
|
|
<TextInput
|
|
style={[styles.modalInput, validationError && styles.modalInputError]}
|
|
placeholder="e.g., 21"
|
|
placeholderTextColor={AppColors.textMuted}
|
|
value={tempDeploymentId}
|
|
onChangeText={(text) => {
|
|
setTempDeploymentId(text);
|
|
setValidationError(null);
|
|
}}
|
|
keyboardType="numeric"
|
|
autoFocus
|
|
editable={!isValidating}
|
|
/>
|
|
{validationError && (
|
|
<Text style={styles.errorText}>{validationError}</Text>
|
|
)}
|
|
<View style={styles.modalButtons}>
|
|
<TouchableOpacity
|
|
style={styles.modalButtonCancel}
|
|
onPress={() => setShowDeploymentModal(false)}
|
|
disabled={isValidating}
|
|
>
|
|
<Text style={[styles.modalButtonCancelText, isValidating && styles.disabledText]}>Cancel</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.modalButtonSave, isValidating && styles.modalButtonDisabled]}
|
|
onPress={saveDeploymentId}
|
|
disabled={isValidating}
|
|
>
|
|
<Text style={styles.modalButtonSaveText}>
|
|
{isValidating ? 'Validating...' : 'Save'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
|
|
{/* Voice API Modal */}
|
|
<Modal
|
|
visible={showVoiceApiModal}
|
|
transparent
|
|
animationType="fade"
|
|
onRequestClose={() => setShowVoiceApiModal(false)}
|
|
>
|
|
<View style={styles.modalOverlay}>
|
|
<View style={styles.modalContent}>
|
|
<Text style={styles.modalTitle}>Voice API</Text>
|
|
<Text style={styles.modalDescription}>
|
|
Choose which API function to use for voice requests.
|
|
</Text>
|
|
|
|
{/* Radio buttons */}
|
|
<TouchableOpacity
|
|
style={styles.radioOption}
|
|
onPress={() => setTempVoiceApiType('ask_wellnuo_ai')}
|
|
>
|
|
<View style={styles.radioCircle}>
|
|
{tempVoiceApiType === 'ask_wellnuo_ai' && <View style={styles.radioCircleSelected} />}
|
|
</View>
|
|
<View style={styles.radioTextContainer}>
|
|
<Text style={styles.radioLabel}>ask_wellnuo_ai</Text>
|
|
<Text style={styles.radioDescription}>LLaMA with WellNuo data</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.radioOption}
|
|
onPress={() => setTempVoiceApiType('voice_ask')}
|
|
>
|
|
<View style={styles.radioCircle}>
|
|
{tempVoiceApiType === 'voice_ask' && <View style={styles.radioCircleSelected} />}
|
|
</View>
|
|
<View style={styles.radioTextContainer}>
|
|
<Text style={styles.radioLabel}>voice_ask</Text>
|
|
<Text style={styles.radioDescription}>Alternative voice API</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
<View style={styles.modalButtons}>
|
|
<TouchableOpacity
|
|
style={styles.modalButtonCancel}
|
|
onPress={() => setShowVoiceApiModal(false)}
|
|
>
|
|
<Text style={styles.modalButtonCancelText}>Cancel</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={styles.modalButtonSave}
|
|
onPress={saveVoiceApiType}
|
|
>
|
|
<Text style={styles.modalButtonSaveText}>Save</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.surface,
|
|
},
|
|
header: {
|
|
paddingHorizontal: Spacing.lg,
|
|
paddingVertical: Spacing.md,
|
|
backgroundColor: AppColors.background,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
headerTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: '700',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
userCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.background,
|
|
padding: Spacing.lg,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
avatarContainer: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: BorderRadius.full,
|
|
backgroundColor: AppColors.primary,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
avatarText: {
|
|
fontSize: FontSizes['2xl'],
|
|
fontWeight: '600',
|
|
color: AppColors.white,
|
|
},
|
|
userInfo: {
|
|
flex: 1,
|
|
marginLeft: Spacing.md,
|
|
},
|
|
userName: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
editButton: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: BorderRadius.full,
|
|
backgroundColor: AppColors.surface,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
section: {
|
|
marginBottom: Spacing.md,
|
|
},
|
|
sectionTitle: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '600',
|
|
color: AppColors.textSecondary,
|
|
paddingHorizontal: Spacing.lg,
|
|
paddingVertical: Spacing.sm,
|
|
textTransform: 'uppercase',
|
|
},
|
|
menuCard: {
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
menuItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.md,
|
|
paddingHorizontal: Spacing.lg,
|
|
},
|
|
menuIconContainer: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: BorderRadius.md,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
menuTextContainer: {
|
|
flex: 1,
|
|
marginLeft: Spacing.md,
|
|
},
|
|
menuTitle: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '500',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
menuSubtitle: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
marginTop: 2,
|
|
},
|
|
menuDivider: {
|
|
height: 1,
|
|
backgroundColor: AppColors.border,
|
|
marginLeft: Spacing.lg + 36 + Spacing.md,
|
|
},
|
|
logoutButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.background,
|
|
paddingVertical: Spacing.md,
|
|
marginHorizontal: Spacing.lg,
|
|
borderRadius: BorderRadius.lg,
|
|
},
|
|
logoutText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
color: AppColors.error,
|
|
marginLeft: Spacing.sm,
|
|
},
|
|
version: {
|
|
textAlign: 'center',
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
paddingVertical: Spacing.xl,
|
|
},
|
|
// Modal styles
|
|
modalOverlay: {
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
padding: Spacing.lg,
|
|
},
|
|
modalContent: {
|
|
backgroundColor: AppColors.background,
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.lg,
|
|
width: '100%',
|
|
maxWidth: 400,
|
|
},
|
|
modalTitle: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
modalDescription: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
modalInput: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.md,
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm + 4,
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textPrimary,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
modalInputError: {
|
|
borderColor: AppColors.error,
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
errorText: {
|
|
color: AppColors.error,
|
|
fontSize: FontSizes.sm,
|
|
marginBottom: Spacing.md,
|
|
},
|
|
modalButtons: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
gap: Spacing.sm,
|
|
},
|
|
modalButtonCancel: {
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
},
|
|
modalButtonCancelText: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
modalButtonSave: {
|
|
backgroundColor: AppColors.primary,
|
|
paddingHorizontal: Spacing.lg,
|
|
paddingVertical: Spacing.sm,
|
|
borderRadius: BorderRadius.md,
|
|
},
|
|
modalButtonSaveText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
color: AppColors.white,
|
|
},
|
|
modalButtonDisabled: {
|
|
backgroundColor: AppColors.textMuted,
|
|
},
|
|
disabledText: {
|
|
opacity: 0.5,
|
|
},
|
|
// Radio button styles
|
|
radioOption: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.sm + 4,
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
radioCircle: {
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
borderWidth: 2,
|
|
borderColor: AppColors.primary,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: Spacing.md,
|
|
},
|
|
radioCircleSelected: {
|
|
width: 12,
|
|
height: 12,
|
|
borderRadius: 6,
|
|
backgroundColor: AppColors.primary,
|
|
},
|
|
radioTextContainer: {
|
|
flex: 1,
|
|
},
|
|
radioLabel: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '500',
|
|
color: AppColors.textPrimary,
|
|
marginBottom: 2,
|
|
},
|
|
radioDescription: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
});
|