wellnua-lite/app/(tabs)/debug.tsx
Sergei a2eb4e6882 Fix iOS audio and transcript streaming
- Add AVAudioSession configuration via @livekit/react-native
- Configure playAndRecord, defaultToSpeaker, voiceChat for iOS
- Fix transcript spam: update existing message until isFinal
- Remove unused Sherpa TTS service
- Add simulator build profile to eas.json
2026-01-16 13:56:29 -08:00

516 lines
14 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
TextInput,
Share,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { debugLogger, type LogEntry } from '@/services/DebugLogger';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import * as Speech from 'expo-speech';
export default function DebugScreen() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filter, setFilter] = useState<string>('');
const [selectedCategory, setSelectedCategory] = useState<string>('All');
const [ttsState, setTtsState] = useState({ initialized: true, initializing: false, error: null as string | null });
const flatListRef = useRef<FlatList>(null);
// Initialize TTS (expo-speech is always available)
useEffect(() => {
debugLogger.info('TTS', 'Using Expo Speech (always ready)');
return () => {
Speech.stop();
};
}, []);
// Subscribe to log updates
useEffect(() => {
const unsubscribe = debugLogger.subscribe((newLogs) => {
setLogs(newLogs);
// Auto-scroll to bottom when new logs arrive
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
});
// Initial load
setLogs(debugLogger.getLogs());
return unsubscribe;
}, []);
// Get unique categories
const categories = ['All', ...new Set(logs.map(log => log.category))];
// Filter logs
const filteredLogs = logs.filter(log => {
const matchesCategory = selectedCategory === 'All' || log.category === selectedCategory;
const matchesFilter = !filter || log.message.toLowerCase().includes(filter.toLowerCase());
return matchesCategory && matchesFilter;
});
// Clear logs
const handleClear = () => {
debugLogger.clear();
};
// Export logs
const handleExport = async () => {
const text = debugLogger.exportAsText();
try {
await Share.share({
message: text,
title: 'Debug Logs Export',
});
} catch (error) {
console.error('Failed to export logs:', error);
}
};
// Test TTS
const handleTestTTS = () => {
debugLogger.info('TTS', 'Testing voice...');
Speech.speak('Hello, this is a test message', {
onDone: () => debugLogger.info('TTS', 'Voice test complete'),
onError: (e) => debugLogger.error('TTS', `Voice test failed: ${e}`)
});
};
// Get TTS status display
const getTTSStatus = () => {
if (ttsState.initializing) {
return {
text: ttsState.error || 'Downloading voice model...',
color: AppColors.warning || '#FF9800',
icon: 'cloud-download' as const,
};
}
if (ttsState.initialized) {
return {
text: 'Ready',
color: '#4CAF50',
icon: 'checkmark-circle' as const,
};
}
if (ttsState.error) {
return {
text: ttsState.error,
color: AppColors.error || '#E53935',
icon: 'alert-circle' as const,
};
}
return {
text: 'Not initialized',
color: AppColors.textMuted,
icon: 'time' as const,
};
};
const ttsStatus = getTTSStatus();
// Get log level color
const getLevelColor = (level: LogEntry['level']): string => {
switch (level) {
case 'error':
return AppColors.error || '#E53935';
case 'warn':
return AppColors.warning || '#FF9800';
case 'info':
return AppColors.primary;
default:
return AppColors.textSecondary;
}
};
// Get level icon
const getLevelIcon = (level: LogEntry['level']): any => {
switch (level) {
case 'error':
return 'close-circle';
case 'warn':
return 'warning';
case 'info':
return 'information-circle';
default:
return 'chatbubble-ellipses';
}
};
// Render log item
const renderLog = ({ item }: { item: LogEntry }) => {
const time = item.timestamp.toLocaleTimeString();
const levelColor = getLevelColor(item.level);
return (
<View style={styles.logItem}>
<View style={styles.logHeader}>
<View style={styles.logInfo}>
<Ionicons name={getLevelIcon(item.level)} size={16} color={levelColor} />
<Text style={[styles.logLevel, { color: levelColor }]}>
{item.level.toUpperCase()}
</Text>
<Text style={styles.logCategory}>[{item.category}]</Text>
<Text style={styles.logTime}>{time}</Text>
</View>
</View>
<Text style={styles.logMessage}>{item.message}</Text>
{item.data && (
<Text style={styles.logData}>
{typeof item.data === 'object' ? JSON.stringify(item.data, null, 2) : String(item.data)}
</Text>
)}
</View>
);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Debug Console</Text>
<View style={styles.headerButtons}>
<TouchableOpacity style={styles.headerButton} onPress={handleExport}>
<Ionicons name="share-outline" size={22} color={AppColors.primary} />
</TouchableOpacity>
<TouchableOpacity style={styles.headerButton} onPress={handleClear}>
<Ionicons name="trash-outline" size={22} color={AppColors.error || '#E53935'} />
</TouchableOpacity>
</View>
</View>
{/* TTS Test Button */}
<View style={styles.ttsTestContainer}>
{/* TTS Status Indicator */}
<View style={styles.ttsStatusRow}>
<Ionicons name={ttsStatus.icon} size={16} color={ttsStatus.color} />
<Text style={[styles.ttsStatusText, { color: ttsStatus.color }]}>
{ttsStatus.text}
</Text>
</View>
<TouchableOpacity
style={[
styles.testButton,
!ttsState.initialized && styles.testButtonDisabled
]}
onPress={handleTestTTS}
disabled={!ttsState.initialized}
>
<Ionicons
name={ttsState.speaking ? "stop-circle" : "volume-high"}
size={20}
color={ttsState.initialized ? AppColors.white : AppColors.textMuted}
/>
<Text
style={[
styles.testButtonText,
!ttsState.initialized && styles.testButtonTextDisabled
]}
>
{ttsState.speaking ? 'Stop' : 'Test Voice'}
</Text>
</TouchableOpacity>
</View>
{/* Stats Bar */}
<View style={styles.statsBar}>
<View style={styles.statItem}>
<Text style={styles.statLabel}>Total:</Text>
<Text style={styles.statValue}>{logs.length}</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statLabel, { color: AppColors.error || '#E53935' }]}>Errors:</Text>
<Text style={[styles.statValue, { color: AppColors.error || '#E53935' }]}>
{logs.filter(l => l.level === 'error').length}
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statLabel, { color: AppColors.warning || '#FF9800' }]}>Warns:</Text>
<Text style={[styles.statValue, { color: AppColors.warning || '#FF9800' }]}>
{logs.filter(l => l.level === 'warn').length}
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statLabel, { color: AppColors.primary }]}>Filtered:</Text>
<Text style={[styles.statValue, { color: AppColors.primary }]}>
{filteredLogs.length}
</Text>
</View>
</View>
{/* Category Filter */}
<View style={styles.filterContainer}>
<FlatList
horizontal
data={categories}
keyExtractor={(item) => item}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.categoryChip,
selectedCategory === item && styles.categoryChipActive,
]}
onPress={() => setSelectedCategory(item)}
>
<Text
style={[
styles.categoryChipText,
selectedCategory === item && styles.categoryChipTextActive,
]}
>
{item}
</Text>
</TouchableOpacity>
)}
contentContainerStyle={styles.categoryList}
showsHorizontalScrollIndicator={false}
/>
</View>
{/* Search Filter */}
<View style={styles.searchContainer}>
<Ionicons name="search" size={20} color={AppColors.textMuted} style={styles.searchIcon} />
<TextInput
style={styles.searchInput}
placeholder="Filter logs..."
placeholderTextColor={AppColors.textMuted}
value={filter}
onChangeText={setFilter}
/>
{filter.length > 0 && (
<TouchableOpacity onPress={() => setFilter('')}>
<Ionicons name="close-circle" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
)}
</View>
{/* Logs List */}
<FlatList
ref={flatListRef}
data={filteredLogs}
keyExtractor={(item) => item.id}
renderItem={renderLog}
contentContainerStyle={styles.logsList}
showsVerticalScrollIndicator={true}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="bug-outline" size={64} color={AppColors.textMuted} />
<Text style={styles.emptyText}>No logs yet</Text>
<Text style={styles.emptySubtext}>
Voice and system logs will appear here
</Text>
</View>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.surface,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
headerTitle: {
fontSize: FontSizes.xl,
fontWeight: '600',
color: AppColors.textPrimary,
},
headerButtons: {
flexDirection: 'row',
gap: Spacing.sm,
},
headerButton: {
padding: Spacing.xs,
},
statsBar: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.surface,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
statItem: {
alignItems: 'center',
},
statLabel: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
marginBottom: 2,
},
statValue: {
fontSize: FontSizes.lg,
fontWeight: '600',
color: AppColors.textPrimary,
},
filterContainer: {
backgroundColor: AppColors.surface,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
categoryList: {
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
},
categoryChip: {
paddingHorizontal: Spacing.sm + 4,
paddingVertical: Spacing.xs,
borderRadius: BorderRadius.full,
backgroundColor: AppColors.background,
marginHorizontal: 4,
borderWidth: 1,
borderColor: AppColors.border,
},
categoryChipActive: {
backgroundColor: AppColors.primary,
borderColor: AppColors.primary,
},
categoryChipText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
fontWeight: '500',
},
categoryChipTextActive: {
color: AppColors.white,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.surface,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
searchIcon: {
marginRight: Spacing.xs,
},
searchInput: {
flex: 1,
fontSize: FontSizes.base,
color: AppColors.textPrimary,
paddingVertical: 0,
},
logsList: {
padding: Spacing.sm,
},
logItem: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.md,
padding: Spacing.sm,
marginBottom: Spacing.sm,
borderLeftWidth: 3,
borderLeftColor: AppColors.primary,
},
logHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: Spacing.xs,
},
logInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
logLevel: {
fontSize: FontSizes.xs,
fontWeight: '600',
},
logCategory: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
fontWeight: '500',
},
logTime: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
},
logMessage: {
fontSize: FontSizes.sm,
color: AppColors.textPrimary,
lineHeight: 20,
},
logData: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
marginTop: Spacing.xs,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 100,
},
emptyText: {
fontSize: FontSizes.lg,
fontWeight: '600',
color: AppColors.textSecondary,
marginTop: Spacing.md,
},
emptySubtext: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
marginTop: Spacing.xs,
},
ttsTestContainer: {
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: AppColors.surface,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
gap: Spacing.xs,
},
testButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
borderRadius: BorderRadius.md,
gap: Spacing.xs,
},
testButtonText: {
fontSize: FontSizes.base,
fontWeight: '600',
color: AppColors.white,
},
testButtonDisabled: {
backgroundColor: AppColors.border,
opacity: 0.6,
},
testButtonTextDisabled: {
color: AppColors.textMuted,
},
ttsStatusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
},
ttsStatusText: {
fontSize: FontSizes.sm,
fontWeight: '500',
},
});