feat: migrate from NativeWind to react-native-unistyles and add Speechmatics audio

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-10-22 14:38:46 +02:00
parent c30ef34ef7
commit 32d4944c58
26 changed files with 3157 additions and 1344 deletions

View File

@@ -1,4 +1,4 @@
import '../global.css';
import '@/styles/unistyles';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';

View File

@@ -1,8 +1,231 @@
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
import { useState, useEffect } from 'react';
import { StyleSheet } from 'react-native-unistyles';
import { useAudioRecorder } from '../hooks/use-audio-recorder';
import { useAudioPlayer } from '../hooks/use-audio-player';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
import type { Theme } from '../styles/theme';
const styles = StyleSheet.create((theme: Theme) => ({
scrollView: {
flex: 1,
backgroundColor: theme.colors.white,
},
scrollViewDark: {
backgroundColor: theme.colors.black,
},
container: {
padding: theme.spacing[6],
},
title: {
fontSize: theme.fontSize['3xl'],
fontWeight: theme.fontWeight.bold,
color: theme.colors.gray[900],
marginBottom: theme.spacing[2],
},
titleDark: {
color: theme.colors.white,
},
subtitle: {
fontSize: theme.fontSize.base,
color: theme.colors.gray[600],
marginBottom: theme.spacing[8],
},
subtitleDark: {
color: theme.colors.gray[400],
},
section: {
marginBottom: theme.spacing[8],
},
sectionTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.gray[900],
marginBottom: theme.spacing[2],
},
sectionTitleDark: {
color: theme.colors.white,
},
permissionCard: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
},
permissionGranted: {
backgroundColor: theme.colors.green[100],
},
permissionGrantedDark: {
backgroundColor: theme.colors.green[900],
},
permissionDenied: {
backgroundColor: theme.colors.red[100],
},
permissionDeniedDark: {
backgroundColor: theme.colors.red[900],
},
permissionText: {
fontSize: theme.fontSize.base,
},
permissionTextGranted: {
color: theme.colors.green[800],
},
permissionTextGrantedDark: {
color: theme.colors.green[200],
},
permissionTextDenied: {
color: theme.colors.red[800],
},
permissionTextDeniedDark: {
color: theme.colors.red[200],
},
statusCard: {
marginBottom: theme.spacing[4],
padding: theme.spacing[4],
backgroundColor: theme.colors.gray[100],
borderRadius: theme.borderRadius.lg,
},
statusCardDark: {
backgroundColor: theme.colors.gray[800],
},
statusLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.gray[700],
marginBottom: theme.spacing[1],
},
statusLabelDark: {
color: theme.colors.gray[300],
},
statusValue: {
fontSize: theme.fontSize.base,
color: theme.colors.gray[900],
},
statusValueDark: {
color: theme.colors.white,
},
controlsRow: {
flexDirection: 'row',
gap: theme.spacing[3],
marginBottom: theme.spacing[4],
},
button: {
flex: 1,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: 'center',
},
buttonBlue: {
backgroundColor: theme.colors.blue[500],
},
buttonBlueDark: {
backgroundColor: theme.colors.blue[600],
},
buttonRed: {
backgroundColor: theme.colors.red[500],
},
buttonRedDark: {
backgroundColor: theme.colors.red[600],
},
buttonGreen: {
backgroundColor: theme.colors.green[500],
},
buttonGreenDark: {
backgroundColor: theme.colors.green[600],
},
buttonPurple: {
backgroundColor: theme.colors.purple[500],
},
buttonPurpleDark: {
backgroundColor: theme.colors.purple[600],
},
buttonOrange: {
backgroundColor: theme.colors.orange[500],
},
buttonOrangeDark: {
backgroundColor: theme.colors.orange[600],
},
buttonDisabled: {
backgroundColor: theme.colors.gray[300],
},
buttonDisabledDark: {
backgroundColor: theme.colors.gray[700],
},
buttonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.white,
},
buttonTextDisabled: {
color: theme.colors.gray[500],
},
buttonTextDisabledDark: {
color: theme.colors.gray[400],
},
fullWidthButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
alignItems: 'center',
},
infoCard: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.blue[50],
},
infoCardDark: {
backgroundColor: theme.colors.blue[950],
},
infoCardLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.blue[700],
marginBottom: theme.spacing[1],
},
infoCardLabelDark: {
color: theme.colors.blue[300],
},
infoCardValue: {
fontSize: theme.fontSize.base,
color: theme.colors.blue[900],
},
infoCardValueDark: {
color: theme.colors.blue[100],
},
infoCardSubtext: {
fontSize: theme.fontSize.sm,
color: theme.colors.blue[600],
marginTop: theme.spacing[1],
},
infoCardSubtextDark: {
color: theme.colors.blue[400],
},
configCard: {
padding: theme.spacing[4],
backgroundColor: theme.colors.gray[50],
borderRadius: theme.borderRadius.lg,
},
configCardDark: {
backgroundColor: theme.colors.gray[900],
},
configTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.gray[700],
marginBottom: theme.spacing[2],
},
configTitleDark: {
color: theme.colors.gray[300],
},
configText: {
fontSize: theme.fontSize.sm,
color: theme.colors.gray[600],
marginBottom: theme.spacing[1],
},
configTextDark: {
color: theme.colors.gray[400],
},
gap3: {
gap: theme.spacing[3],
},
}));
export default function AudioTestScreen() {
const [permissionGranted, setPermissionGranted] = useState(false);
@@ -124,40 +347,46 @@ export default function AudioTestScreen() {
}
return (
<ScrollView className="flex-1 bg-white dark:bg-black">
<View className="p-6">
<ScrollView style={[styles.scrollView, styles.scrollViewDark]}>
<View style={styles.container}>
{/* Header */}
<Text className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
<Text style={[styles.title, styles.titleDark]}>
Audio Test
</Text>
<Text className="text-base text-gray-600 dark:text-gray-400 mb-8">
<Text style={[styles.subtitle, styles.subtitleDark]}>
Test audio recording and playback functionality
</Text>
{/* Permission Status */}
<View className="mb-8">
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Permission Status
</Text>
<View className={`p-4 rounded-lg ${permissionGranted ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
<Text className={`text-base ${permissionGranted ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}`}>
<View style={[
styles.permissionCard,
permissionGranted ? styles.permissionGrantedDark : styles.permissionDeniedDark
]}>
<Text style={[
styles.permissionText,
permissionGranted ? styles.permissionTextGrantedDark : styles.permissionTextDeniedDark
]}>
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
</Text>
</View>
</View>
{/* Recording Section */}
<View className="mb-8">
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Audio Recording
</Text>
{/* Recording Status */}
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<View style={[styles.statusCard, styles.statusCardDark]}>
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
Status
</Text>
<Text className="text-base text-gray-900 dark:text-white">
<Text style={[styles.statusValue, styles.statusValueDark]}>
{recordingStatus === 'idle' && 'Ready to record'}
{recordingStatus === 'recording' && '🔴 Recording...'}
{recordingStatus === 'processing' && 'Processing...'}
@@ -165,21 +394,21 @@ export default function AudioTestScreen() {
</View>
{/* Recording Controls */}
<View className="flex-row gap-3 mb-4">
<View style={styles.controlsRow}>
<Pressable
onPress={handleStartRecording}
disabled={recordingStatus !== 'idle' || !permissionGranted}
className={`flex-1 p-4 rounded-lg items-center ${
recordingStatus !== 'idle' || !permissionGranted
? 'bg-gray-300 dark:bg-gray-700'
: 'bg-blue-500 dark:bg-blue-600'
}`}
style={[
styles.button,
(recordingStatus !== 'idle' || !permissionGranted)
? styles.buttonDisabledDark
: styles.buttonBlueDark
]}
>
<Text className={`text-base font-semibold ${
recordingStatus !== 'idle' || !permissionGranted
? 'text-gray-500 dark:text-gray-400'
: 'text-white'
}`}>
<Text style={[
styles.buttonText,
(recordingStatus !== 'idle' || !permissionGranted) && styles.buttonTextDisabledDark
]}>
Start Recording
</Text>
</Pressable>
@@ -187,17 +416,17 @@ export default function AudioTestScreen() {
<Pressable
onPress={handleStopRecording}
disabled={recordingStatus !== 'recording'}
className={`flex-1 p-4 rounded-lg items-center ${
style={[
styles.button,
recordingStatus !== 'recording'
? 'bg-gray-300 dark:bg-gray-700'
: 'bg-red-500 dark:bg-red-600'
}`}
? styles.buttonDisabledDark
: styles.buttonRedDark
]}
>
<Text className={`text-base font-semibold ${
recordingStatus !== 'recording'
? 'text-gray-500 dark:text-gray-400'
: 'text-white'
}`}>
<Text style={[
styles.buttonText,
recordingStatus !== 'recording' && styles.buttonTextDisabledDark
]}>
Stop Recording
</Text>
</Pressable>
@@ -205,14 +434,14 @@ export default function AudioTestScreen() {
{/* Last Recording Info */}
{lastRecordingSize !== null && (
<View className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<Text className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-1">
<View style={[styles.infoCard, styles.infoCardDark]}>
<Text style={[styles.infoCardLabel, styles.infoCardLabelDark]}>
Last Recording
</Text>
<Text className="text-base text-blue-900 dark:text-blue-100">
<Text style={[styles.infoCardValue, styles.infoCardValueDark]}>
Size: {lastRecordingSize} bytes
</Text>
<Text className="text-sm text-blue-600 dark:text-blue-400 mt-1">
<Text style={[styles.infoCardSubtext, styles.infoCardSubtextDark]}>
Type: audio/m4a
</Text>
</View>
@@ -220,55 +449,55 @@ export default function AudioTestScreen() {
</View>
{/* Playback Section */}
<View className="mb-8">
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
<View style={styles.section}>
<Text style={[styles.sectionTitle, styles.sectionTitleDark]}>
Audio Playback
</Text>
{/* Playback Status */}
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<View style={[styles.statusCard, styles.statusCardDark]}>
<Text style={[styles.statusLabel, styles.statusLabelDark]}>
Status
</Text>
<Text className="text-base text-gray-900 dark:text-white">
<Text style={[styles.statusValue, styles.statusValueDark]}>
{playbackStatus}
</Text>
</View>
{/* Playback Controls */}
<View className="gap-3">
<View style={styles.gap3}>
<Pressable
onPress={handlePlayLastRecording}
disabled={!lastRecordedBlob}
className={`p-4 rounded-lg items-center ${
style={[
styles.fullWidthButton,
!lastRecordedBlob
? 'bg-gray-300 dark:bg-gray-700'
: 'bg-green-500 dark:bg-green-600'
}`}
? styles.buttonDisabledDark
: styles.buttonGreenDark
]}
>
<Text className={`text-base font-semibold ${
!lastRecordedBlob
? 'text-gray-500 dark:text-gray-400'
: 'text-white'
}`}>
<Text style={[
styles.buttonText,
!lastRecordedBlob && styles.buttonTextDisabledDark
]}>
Play Last Recording
</Text>
</Pressable>
<Pressable
onPress={handlePlayTestAudio}
className="p-4 rounded-lg items-center bg-purple-500 dark:bg-purple-600"
style={[styles.fullWidthButton, styles.buttonPurpleDark]}
>
<Text className="text-base font-semibold text-white">
<Text style={styles.buttonText}>
Play Test Audio
</Text>
</Pressable>
<Pressable
onPress={handleStopPlayback}
className="p-4 rounded-lg items-center bg-orange-500 dark:bg-orange-600"
style={[styles.fullWidthButton, styles.buttonOrangeDark]}
>
<Text className="text-base font-semibold text-white">
<Text style={styles.buttonText}>
Stop Playback
</Text>
</Pressable>
@@ -276,20 +505,20 @@ export default function AudioTestScreen() {
</View>
{/* Audio Settings Info */}
<View className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<View style={[styles.configCard, styles.configCardDark]}>
<Text style={[styles.configTitle, styles.configTitleDark]}>
Audio Configuration
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
<Text style={[styles.configText, styles.configTextDark]}>
Sample Rate: 16000 Hz
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
<Text style={[styles.configText, styles.configTextDark]}>
Channels: 1 (Mono)
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
<Text style={[styles.configText, styles.configTextDark]}>
Format: M4A/AAC
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400">
<Text style={[styles.configText, styles.configTextDark]}>
Optimized for voice/speech
</Text>
</View>

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,235 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView, TextInput, Switch, Pressable, Alert, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useSettings } from '@/hooks/use-settings';
import { useState, useEffect } from "react";
import {
View,
Text,
ScrollView,
TextInput,
Switch,
Pressable,
Alert,
ActivityIndicator,
} from "react-native";
import { router } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { useSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
const styles = StyleSheet.create((theme: Theme) => ({
loadingContainer: {
flex: 1,
backgroundColor: theme.colors.black,
alignItems: "center",
justifyContent: "center",
},
loadingText: {
color: theme.colors.white,
fontSize: theme.fontSize.lg,
},
container: {
flex: 1,
backgroundColor: theme.colors.black,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.zinc[800],
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
headerTitle: {
color: theme.colors.white,
fontSize: theme.fontSize["3xl"],
fontWeight: theme.fontWeight.bold,
},
cancelButton: {
color: theme.colors.blue[500],
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
content: {
padding: theme.spacing[6],
},
section: {
marginBottom: theme.spacing[8],
},
sectionTitle: {
color: theme.colors.white,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[4],
},
label: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[2],
},
input: {
backgroundColor: theme.colors.zinc[900],
color: theme.colors.white,
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
},
helperText: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[3],
},
testButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[3],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.blue[600],
},
testButtonDisabled: {
backgroundColor: theme.colors.zinc[800],
},
testButtonText: {
color: theme.colors.white,
fontWeight: theme.fontWeight.semibold,
marginLeft: theme.spacing[2],
},
testResultSuccess: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: "#14532d",
borderWidth: theme.borderWidth[1],
borderColor: "#15803d",
},
testResultError: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: "#7f1d1d",
borderWidth: theme.borderWidth[1],
borderColor: "#b91c1c",
},
testResultTextSuccess: {
color: "#4ade80",
},
testResultTextError: {
color: "#f87171",
},
settingCard: {
backgroundColor: theme.colors.zinc[900],
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
marginBottom: theme.spacing[3],
},
settingRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
settingContent: {
flex: 1,
},
settingTitle: {
color: theme.colors.white,
fontSize: theme.fontSize.base,
marginBottom: theme.spacing[1],
},
settingDescription: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
},
themeCardDisabled: {
backgroundColor: theme.colors.zinc[900],
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
opacity: theme.opacity[50],
},
themeHelpText: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
marginBottom: theme.spacing[3],
},
themeOption: {
flexDirection: "row",
alignItems: "center",
paddingVertical: theme.spacing[2],
},
radioOuter: {
width: 20,
height: 20,
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[2],
marginRight: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
},
radioOuterSelected: {
borderColor: theme.colors.blue[500],
},
radioOuterUnselected: {
borderColor: theme.colors.zinc[600],
},
radioInner: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.blue[500],
},
themeOptionText: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.base,
textTransform: "capitalize",
},
saveButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[3],
backgroundColor: theme.colors.blue[500],
},
saveButtonDisabled: {
backgroundColor: "#1e3a8a",
opacity: theme.opacity[50],
},
saveButtonText: {
color: theme.colors.white,
textAlign: "center",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
resetButton: {
padding: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: "#7f1d1d",
},
resetButtonText: {
color: theme.colors.red[500],
textAlign: "center",
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
footer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.zinc[800],
paddingTop: theme.spacing[6],
},
footerText: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.sm,
textAlign: "center",
},
footerVersion: {
color: theme.colors.zinc[600],
fontSize: theme.fontSize.xs,
textAlign: "center",
marginTop: theme.spacing[1],
},
}));
export default function SettingsScreen() {
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
@@ -11,10 +238,13 @@ export default function SettingsScreen() {
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
const [theme, setTheme] = useState<'dark' | 'light' | 'auto'>(settings.theme);
const [theme, setTheme] = useState<"dark" | "light" | "auto">(settings.theme);
const [hasChanges, setHasChanges] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
// Update local state when settings load
useEffect(() => {
@@ -37,7 +267,7 @@ export default function SettingsScreen() {
function validateServerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'ws:' || urlObj.protocol === 'wss:';
return urlObj.protocol === "ws:" || urlObj.protocol === "wss:";
} catch {
return false;
}
@@ -47,9 +277,9 @@ export default function SettingsScreen() {
// Validate server URL
if (!validateServerUrl(serverUrl)) {
Alert.alert(
'Invalid URL',
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
[{ text: 'OK' }]
"Invalid URL",
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
[{ text: "OK" }]
);
return;
}
@@ -63,39 +293,43 @@ export default function SettingsScreen() {
});
Alert.alert(
'Settings Saved',
'Your settings have been saved successfully.',
"Settings Saved",
"Your settings have been saved successfully.",
[
{
text: 'OK',
text: "OK",
onPress: () => router.back(),
},
]
);
} catch (error) {
Alert.alert(
'Error',
'Failed to save settings. Please try again.',
[{ text: 'OK' }]
);
Alert.alert("Error", "Failed to save settings. Please try again.", [
{ text: "OK" },
]);
}
}
async function handleReset() {
Alert.alert(
'Reset Settings',
'Are you sure you want to reset all settings to defaults?',
"Reset Settings",
"Are you sure you want to reset all settings to defaults?",
[
{ text: 'Cancel', style: 'cancel' },
{ text: "Cancel", style: "cancel" },
{
text: 'Reset',
style: 'destructive',
text: "Reset",
style: "destructive",
onPress: async () => {
try {
await resetSettings();
Alert.alert('Settings Reset', 'All settings have been reset to defaults.');
Alert.alert(
"Settings Reset",
"All settings have been reset to defaults."
);
} catch (error) {
Alert.alert('Error', 'Failed to reset settings. Please try again.');
Alert.alert(
"Error",
"Failed to reset settings. Please try again."
);
}
},
},
@@ -106,13 +340,13 @@ export default function SettingsScreen() {
function handleCancel() {
if (hasChanges) {
Alert.alert(
'Discard Changes',
'You have unsaved changes. Are you sure you want to go back?',
"Discard Changes",
"You have unsaved changes. Are you sure you want to go back?",
[
{ text: 'Stay', style: 'cancel' },
{ text: "Stay", style: "cancel" },
{
text: 'Discard',
style: 'destructive',
text: "Discard",
style: "destructive",
onPress: () => router.back(),
},
]
@@ -125,9 +359,9 @@ export default function SettingsScreen() {
async function handleTestConnection() {
if (!validateServerUrl(serverUrl)) {
Alert.alert(
'Invalid URL',
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
[{ text: 'OK' }]
"Invalid URL",
"Server URL must be a valid WebSocket URL (ws:// or wss://)",
[{ text: "OK" }]
);
return;
}
@@ -142,7 +376,7 @@ export default function SettingsScreen() {
ws.close();
setTestResult({
success: false,
message: 'Connection timeout - server did not respond',
message: "Connection timeout - server did not respond",
});
setIsTesting(false);
}, 5000);
@@ -152,7 +386,7 @@ export default function SettingsScreen() {
ws.close();
setTestResult({
success: true,
message: 'Connection successful',
message: "Connection successful",
});
setIsTesting(false);
};
@@ -161,14 +395,14 @@ export default function SettingsScreen() {
clearTimeout(timeout);
setTestResult({
success: false,
message: 'Connection failed - check URL and network',
message: "Connection failed - check URL and network",
});
setIsTesting(false);
};
} catch (error) {
setTestResult({
success: false,
message: 'Failed to create connection',
message: "Failed to create connection",
});
setIsTesting(false);
}
@@ -176,35 +410,33 @@ export default function SettingsScreen() {
if (isLoading) {
return (
<View className="flex-1 bg-black items-center justify-center">
<Text className="text-white text-lg">Loading settings...</Text>
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading settings...</Text>
</View>
);
}
return (
<View className="flex-1 bg-black">
<View style={styles.container}>
{/* Header */}
<View className="px-6 pb-4 border-b border-gray-800" style={{ paddingTop: insets.top + 16 }}>
<View className="flex-row items-center justify-between">
<Text className="text-white text-3xl font-bold">Settings</Text>
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
<View style={styles.headerRow}>
<Text style={styles.headerTitle}>Settings</Text>
<Pressable onPress={handleCancel}>
<Text className="text-blue-500 text-base font-semibold">Cancel</Text>
<Text style={styles.cancelButton}>Cancel</Text>
</Pressable>
</View>
</View>
<ScrollView className="flex-1">
<View className="p-6">
<ScrollView style={styles.scrollView}>
<View style={styles.content}>
{/* Server Configuration */}
<View className="mb-8">
<Text className="text-white text-lg font-semibold mb-4">
Server Configuration
</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Server Configuration</Text>
<Text className="text-gray-400 text-sm mb-2">WebSocket URL</Text>
<Text style={styles.label}>WebSocket URL</Text>
<TextInput
className="bg-zinc-900 text-white p-4 rounded-lg mb-2"
style={styles.input}
placeholder="wss://example.com/ws"
placeholderTextColor="#6b7280"
value={serverUrl}
@@ -216,7 +448,7 @@ export default function SettingsScreen() {
autoCorrect={false}
keyboardType="url"
/>
<Text className="text-gray-500 text-xs mb-3">
<Text style={styles.helperText}>
Must be a valid WebSocket URL (ws:// or wss://)
</Text>
@@ -224,30 +456,38 @@ export default function SettingsScreen() {
<Pressable
onPress={handleTestConnection}
disabled={isTesting || !validateServerUrl(serverUrl)}
className={`p-3 rounded-lg mb-3 flex-row items-center justify-center ${
isTesting || !validateServerUrl(serverUrl) ? 'bg-zinc-800' : 'bg-blue-600 active:bg-blue-700'
}`}
style={[
styles.testButton,
(isTesting || !validateServerUrl(serverUrl)) &&
styles.testButtonDisabled,
]}
>
{isTesting ? (
<>
<ActivityIndicator size="small" color="#fff" />
<Text className="text-white font-semibold ml-2">Testing...</Text>
<Text style={styles.testButtonText}>Testing...</Text>
</>
) : (
<Text className="text-white font-semibold">Test Connection</Text>
<Text style={styles.testButtonText}>Test Connection</Text>
)}
</Pressable>
{/* Test Result */}
{testResult && (
<View
className={`p-3 rounded-lg ${
style={
testResult.success
? 'bg-green-900/30 border border-green-700'
: 'bg-red-900/30 border border-red-700'
}`}
? styles.testResultSuccess
: styles.testResultError
}
>
<Text className={testResult.success ? 'text-green-400' : 'text-red-400'}>
<Text
style={
testResult.success
? styles.testResultTextSuccess
: styles.testResultTextError
}
>
{testResult.message}
</Text>
</View>
@@ -255,110 +495,99 @@ export default function SettingsScreen() {
</View>
{/* Audio Settings */}
<View className="mb-8">
<Text className="text-white text-lg font-semibold mb-4">
Audio
</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Audio</Text>
<View className="bg-zinc-900 rounded-lg p-4 mb-3">
<View className="flex-row justify-between items-center">
<View className="flex-1">
<Text className="text-white text-base mb-1">Use Speaker</Text>
<Text className="text-gray-400 text-sm">
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Use Speaker</Text>
<Text style={styles.settingDescription}>
Play audio through speaker instead of earpiece
</Text>
</View>
<Switch
value={useSpeaker}
onValueChange={setUseSpeaker}
trackColor={{ false: '#374151', true: '#3b82f6' }}
thumbColor={useSpeaker ? '#60a5fa' : '#d1d5db'}
trackColor={{ false: "#374151", true: "#3b82f6" }}
thumbColor={useSpeaker ? "#60a5fa" : "#d1d5db"}
/>
</View>
</View>
<View className="bg-zinc-900 rounded-lg p-4">
<View className="flex-row justify-between items-center">
<View className="flex-1">
<Text className="text-white text-base mb-1">Keep Screen On</Text>
<Text className="text-gray-400 text-sm">
<View style={styles.settingCard}>
<View style={styles.settingRow}>
<View style={styles.settingContent}>
<Text style={styles.settingTitle}>Keep Screen On</Text>
<Text style={styles.settingDescription}>
Prevent screen from sleeping during voice sessions
</Text>
</View>
<Switch
value={keepScreenOn}
onValueChange={setKeepScreenOn}
trackColor={{ false: '#374151', true: '#3b82f6' }}
thumbColor={keepScreenOn ? '#60a5fa' : '#d1d5db'}
trackColor={{ false: "#374151", true: "#3b82f6" }}
thumbColor={keepScreenOn ? "#60a5fa" : "#d1d5db"}
/>
</View>
</View>
</View>
{/* Theme Settings */}
<View className="mb-8">
<Text className="text-white text-lg font-semibold mb-4">
Theme
</Text>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Theme</Text>
<View className="bg-zinc-900 rounded-lg p-4 opacity-50">
<Text className="text-gray-400 text-sm mb-3">
<View style={styles.themeCardDisabled}>
<Text style={styles.themeHelpText}>
Theme selection (coming soon)
</Text>
{(['dark', 'light', 'auto'] as const).map((themeOption) => (
{(["dark", "light", "auto"] as const).map((themeOption) => (
<Pressable
key={themeOption}
disabled
className="flex-row items-center py-2"
style={styles.themeOption}
>
<View className={`w-5 h-5 rounded-full border-2 ${
theme === themeOption ? 'border-blue-500' : 'border-gray-600'
} mr-3 items-center justify-center`}>
<View
style={[
styles.radioOuter,
theme === themeOption
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{theme === themeOption && (
<View className="w-3 h-3 rounded-full bg-blue-500" />
<View style={styles.radioInner} />
)}
</View>
<Text className="text-gray-400 text-base capitalize">
{themeOption}
</Text>
<Text style={styles.themeOptionText}>{themeOption}</Text>
</Pressable>
))}
</View>
</View>
{/* Action Buttons */}
<View className="mb-8">
<View style={styles.section}>
<Pressable
className={`p-4 rounded-lg mb-3 ${
hasChanges ? 'bg-blue-500' : 'bg-blue-500/50'
}`}
style={[
styles.saveButton,
!hasChanges && styles.saveButtonDisabled,
]}
onPress={handleSave}
disabled={!hasChanges}
>
<Text className="text-white text-center text-base font-semibold">
Save Settings
</Text>
<Text style={styles.saveButtonText}>Save Settings</Text>
</Pressable>
<Pressable
className="p-4 rounded-lg border border-red-500/30"
onPress={handleReset}
>
<Text className="text-red-500 text-center text-base font-semibold">
Reset to Defaults
</Text>
<Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
</Pressable>
</View>
{/* App Info */}
<View className="border-t border-gray-800 pt-6">
<Text className="text-gray-500 text-sm text-center">
Voice Assistant Mobile
</Text>
<Text className="text-gray-600 text-xs text-center mt-1">
Version 1.0.0
</Text>
<View style={styles.footer}>
<Text style={styles.footerText}>Voice Assistant Mobile</Text>
<Text style={styles.footerVersion}>Version 1.0.0</Text>
</View>
</View>
</ScrollView>

View File

@@ -1,4 +1,5 @@
import { View, Text, Pressable, ScrollView } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
export interface ActiveProcessesProps {
@@ -59,6 +60,81 @@ function getModeColor(modeId?: string): string {
return '#9ca3af'; // gray - unknown
}
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
backgroundColor: theme.colors.zinc[900],
borderBottomWidth: 1,
borderBottomColor: theme.colors.zinc[800],
},
header: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.zinc[800],
},
backButton: {
backgroundColor: theme.colors.zinc[800],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
backButtonActive: {
backgroundColor: theme.colors.zinc[700],
},
backButtonText: {
color: '#fff',
fontSize: theme.fontSize.sm,
fontWeight: '600',
textAlign: 'center',
},
scrollView: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
processItem: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
processItemActive: {
backgroundColor: theme.colors.blue[600],
},
processItemInactive: {
backgroundColor: theme.colors.zinc[800],
},
agentIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.blue[500],
},
commandIcon: {
width: 12,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.purple[500],
},
processText: {
color: '#fff',
fontSize: theme.fontSize.xs,
fontWeight: '500',
},
statusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
modeIndicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
opacity: 0.3,
},
}));
export function ActiveProcesses({
agents,
commands,
@@ -74,29 +150,24 @@ export function ActiveProcesses({
}
return (
<View className="bg-zinc-900 border-b border-zinc-800">
{/* Header with Back button */}
<View style={styles.container}>
{hasActiveProcess && (
<View className="px-4 py-3 border-b border-zinc-800">
<View style={styles.header}>
<Pressable
onPress={onBackToOrchestrator}
className="bg-zinc-800 px-4 py-2 rounded-lg active:bg-zinc-700"
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonActive]}
>
<Text className="text-white text-sm font-semibold text-center">
Back to Chat
</Text>
<Text style={styles.backButtonText}>Back to Chat</Text>
</Pressable>
</View>
)}
{/* Scrollable process list */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-4 py-3"
style={styles.scrollView}
contentContainerStyle={{ gap: 8 }}
>
{/* Agents */}
{agents.map((agent) => {
const modeName = getModeName(agent.currentModeId, agent.availableModes);
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
@@ -105,36 +176,25 @@ export function ActiveProcesses({
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.id)}
className={`flex-row items-center gap-2 px-3 py-2 rounded-lg ${
isActive ? 'bg-blue-600' : 'bg-zinc-800'
} active:opacity-70`}
style={({ pressed }) => [
styles.processItem,
isActive ? styles.processItemActive : styles.processItemInactive,
pressed && { opacity: 0.7 },
]}
>
{/* Agent icon */}
<View className="w-3 h-3 rounded-full bg-blue-500" />
<View style={styles.agentIcon} />
{/* Agent ID (shortened) */}
<Text className="text-white text-xs font-medium">
{agent.id.substring(0, 8)}
</Text>
<Text style={styles.processText}>{agent.id.substring(0, 8)}</Text>
{/* Status indicator */}
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getAgentStatusColor(agent.status) }}
/>
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
{/* Mode indicator */}
{agent.currentModeId && (
<View
className="w-1.5 h-1.5 rounded-full opacity-30"
style={{ backgroundColor: getModeColor(agent.currentModeId) }}
/>
<View style={[styles.modeIndicator, { backgroundColor: getModeColor(agent.currentModeId) }]} />
)}
</Pressable>
);
})}
{/* Commands */}
{commands.map((command) => {
const statusColor = command.isDead
? command.exitCode === 0
@@ -143,23 +203,14 @@ export function ActiveProcesses({
: '#3b82f6';
return (
<View
key={`command-${command.id}`}
className="flex-row items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800"
>
{/* Command icon */}
<View className="w-3 h-3 rounded-sm bg-purple-500" />
<View key={`command-${command.id}`} style={[styles.processItem, styles.processItemInactive]}>
<View style={styles.commandIcon} />
{/* Command name */}
<Text className="text-white text-xs font-medium" numberOfLines={1}>
<Text style={styles.processText} numberOfLines={1}>
{command.name || command.currentCommand.substring(0, 20)}
</Text>
{/* Status indicator */}
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
</View>
);
})}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
import type { SessionNotification } from '@agentclientprotocol/sdk';
@@ -80,11 +81,11 @@ function AgentUpdate({
// Handle message chunks
if (sessionUpdate.kind === 'agent_message_chunk') {
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-white text-sm">{sessionUpdate.chunk || ''}</Text>
<Text style={stylesheet.messageText}>{sessionUpdate.chunk || ''}</Text>
</View>
);
}
@@ -92,21 +93,21 @@ function AgentUpdate({
// Handle tool calls
if (sessionUpdate.kind === 'tool_call') {
return (
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
<View style={stylesheet.collapsibleCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className="px-3 py-2 active:bg-zinc-700"
style={stylesheet.collapsibleHeader}
>
<Text className="text-zinc-500 text-xs mb-1">
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-blue-400 text-sm font-medium">
<Text style={stylesheet.toolCallText}>
Tool Call: {sessionUpdate.toolName || 'unknown'}
</Text>
</Pressable>
{isExpanded && sessionUpdate.arguments && (
<View className="px-3 pb-3 border-t border-zinc-700">
<Text className="text-zinc-400 text-xs font-mono mt-2">
<View style={stylesheet.collapsibleContent}>
<Text style={stylesheet.codeText}>
{JSON.stringify(sessionUpdate.arguments, null, 2)}
</Text>
</View>
@@ -118,11 +119,11 @@ function AgentUpdate({
// Handle tool call updates
if (sessionUpdate.kind === 'tool_call_update') {
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-yellow-400 text-sm">
<Text style={stylesheet.updateStatusText}>
{sessionUpdate.status || 'updating'}
</Text>
</View>
@@ -133,22 +134,22 @@ function AgentUpdate({
if (sessionUpdate.kind === 'available_commands_update') {
const commands = sessionUpdate.commands || [];
return (
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
<View style={stylesheet.collapsibleCard}>
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className="px-3 py-2 active:bg-zinc-700"
style={stylesheet.collapsibleHeader}
>
<Text className="text-zinc-500 text-xs mb-1">
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-green-400 text-sm font-medium">
<Text style={stylesheet.commandsText}>
Available Commands ({commands.length})
</Text>
</Pressable>
{isExpanded && (
<View className="px-3 pb-3 border-t border-zinc-700">
<View style={stylesheet.collapsibleContent}>
{commands.map((cmd: any, idx: number) => (
<Text key={idx} className="text-zinc-400 text-xs mt-1">
<Text key={idx} style={stylesheet.commandItem}>
{cmd.name || cmd}
</Text>
))}
@@ -160,11 +161,11 @@ function AgentUpdate({
// Generic session update
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-zinc-400 text-xs font-mono">
<Text style={stylesheet.codeText}>
{JSON.stringify(sessionUpdate, null, 2)}
</Text>
</View>
@@ -173,11 +174,11 @@ function AgentUpdate({
// Fallback for unknown notification types
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
<View style={stylesheet.updateCard}>
<Text style={stylesheet.timestampText}>
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-zinc-400 text-xs font-mono">
<Text style={stylesheet.codeText}>
{JSON.stringify(notification, null, 2)}
</Text>
</View>
@@ -204,49 +205,51 @@ export function AgentStreamView({
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
return (
<View className="flex-1 bg-black">
<View style={stylesheet.container}>
{/* Header */}
<View className="bg-zinc-900 border-b border-zinc-800 px-4 pb-4" style={{ paddingTop: insets.top + 16 }}>
<View style={[stylesheet.header, { paddingTop: insets.top + 16 }]}>
{/* Back button */}
<Pressable
onPress={onBack}
className="bg-zinc-800 px-4 py-2 rounded-lg mb-4 active:bg-zinc-700"
style={stylesheet.backButton}
>
<Text className="text-white text-sm font-semibold text-center">
<Text style={stylesheet.backButtonText}>
Back to Chat
</Text>
</Pressable>
{/* Agent info */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
<Text className="text-zinc-400 text-sm">Agent:</Text>
<Text className="text-white text-sm font-mono">
<View style={stylesheet.agentInfoRow}>
<View style={stylesheet.agentIdRow}>
<Text style={stylesheet.agentLabel}>Agent:</Text>
<Text style={stylesheet.agentId}>
{agentId.substring(0, 8)}
</Text>
</View>
<View
className="flex-row items-center gap-2 px-3 py-1 rounded-full"
style={{ backgroundColor: getStatusColor(agent.status) }}
style={[
stylesheet.statusBadge,
{ backgroundColor: getStatusColor(agent.status) }
]}
>
<Text className="text-white text-xs">{getStatusIcon(agent.status)}</Text>
<Text className="text-white text-xs font-medium">{agent.status}</Text>
<Text style={stylesheet.statusIcon}>{getStatusIcon(agent.status)}</Text>
<Text style={stylesheet.statusText}>{agent.status}</Text>
</View>
</View>
<Text className="text-zinc-500 text-xs">
<Text style={stylesheet.createdText}>
Created: {formatTimestamp(agent.createdAt)}
</Text>
{/* Control buttons */}
{(canCancel || canKill) && (
<View className="flex-row gap-2 mt-4">
<View style={stylesheet.controlButtons}>
{canCancel && (
<Pressable
onPress={() => onCancelAgent(agentId)}
className="flex-1 bg-orange-600 px-4 py-2 rounded-lg active:bg-orange-700"
style={stylesheet.cancelButton}
>
<Text className="text-white text-sm font-semibold text-center">
<Text style={stylesheet.controlButtonText}>
Cancel
</Text>
</Pressable>
@@ -254,9 +257,9 @@ export function AgentStreamView({
{canKill && (
<Pressable
onPress={() => onKillAgent(agentId)}
className="flex-1 bg-red-600 px-4 py-2 rounded-lg active:bg-red-700"
style={stylesheet.killButton}
>
<Text className="text-white text-sm font-semibold text-center">
<Text style={stylesheet.controlButtonText}>
Kill
</Text>
</Pressable>
@@ -268,12 +271,12 @@ export function AgentStreamView({
{/* Updates list */}
<ScrollView
ref={scrollViewRef}
className="flex-1 px-4"
style={stylesheet.scrollView}
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
>
{updates.length === 0 ? (
<View className="flex-1 items-center justify-center py-12">
<Text className="text-zinc-500 text-sm text-center">
<View style={stylesheet.emptyState}>
<Text style={stylesheet.emptyStateText}>
No updates yet.{'\n'}Waiting for agent activity...
</Text>
</View>
@@ -284,3 +287,167 @@ export function AgentStreamView({
</View>
);
}
const stylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.black,
},
header: {
backgroundColor: theme.colors.zinc[900],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.zinc[800],
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
backButton: {
backgroundColor: theme.colors.zinc[800],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[4],
},
backButtonText: {
color: theme.colors.white,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
agentInfoRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: theme.spacing[3],
},
agentIdRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
agentLabel: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
},
agentId: {
color: theme.colors.white,
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
statusIcon: {
color: theme.colors.white,
fontSize: theme.fontSize.xs,
},
statusText: {
color: theme.colors.white,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
createdText: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.xs,
},
controlButtons: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
cancelButton: {
flex: 1,
backgroundColor: theme.colors.orange[600],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
killButton: {
flex: 1,
backgroundColor: theme.colors.red[600],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
controlButtonText: {
color: theme.colors.white,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
scrollView: {
flex: 1,
paddingHorizontal: theme.spacing[4],
},
emptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[12],
},
emptyStateText: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.sm,
textAlign: "center",
},
updateCard: {
backgroundColor: theme.colors.zinc[800],
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
},
collapsibleCard: {
backgroundColor: theme.colors.zinc[800],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: "hidden",
},
collapsibleHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
collapsibleContent: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.zinc[700],
},
timestampText: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
messageText: {
color: theme.colors.white,
fontSize: theme.fontSize.sm,
},
toolCallText: {
color: "#60a5fa",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
updateStatusText: {
color: "#fbbf24",
fontSize: theme.fontSize.sm,
},
commandsText: {
color: "#4ade80",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
commandItem: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
codeText: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.xs,
fontFamily: "monospace",
marginTop: theme.spacing[2],
},
}));

View File

@@ -1,10 +1,17 @@
import { View, Text, ScrollView, Pressable, Modal } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEffect } from 'react';
import {
View,
Text,
ScrollView,
Pressable,
Modal,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useEffect } from "react";
import { StyleSheet } from "react-native-unistyles";
export interface Artifact {
id: string;
type: 'markdown' | 'diff' | 'image' | 'code';
type: "markdown" | "diff" | "image" | "code";
title: string;
content: string;
isBase64: boolean;
@@ -15,10 +22,145 @@ interface ArtifactDrawerProps {
onClose: () => void;
}
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.black,
flexDirection: "column",
},
header: {
paddingBottom: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.zinc[800],
},
headerRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
titleContainer: {
flex: 1,
marginRight: theme.spacing[4],
},
title: {
color: theme.colors.white,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
badge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
badgeMarkdown: {
backgroundColor: theme.colors.blue[600],
},
badgeDiff: {
backgroundColor: theme.colors.purple[600],
},
badgeImage: {
backgroundColor: theme.colors.green[600],
},
badgeCode: {
backgroundColor: theme.colors.orange[600],
},
badgeText: {
color: theme.colors.white,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
backgroundColor: theme.colors.zinc[800],
width: 40,
height: 40,
borderRadius: 20,
alignItems: "center",
justifyContent: "center",
},
closeButtonText: {
color: theme.colors.white,
fontSize: theme.fontSize["2xl"],
fontWeight: theme.fontWeight.bold,
},
contentScroll: {
flex: 1,
backgroundColor: theme.colors.black,
},
contentScrollContainer: {
padding: theme.spacing[4],
flexGrow: 1,
},
imagePlaceholder: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
imagePlaceholderText: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
},
imagePlaceholderSubtext: {
color: theme.colors.zinc[600],
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[2],
},
codeContainer: {
backgroundColor: theme.colors.zinc[900],
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.zinc[800],
},
codeText: {
color: theme.colors.zinc[300],
fontSize: theme.fontSize.sm,
fontFamily: "monospace",
},
metadataContainer: {
backgroundColor: theme.colors.zinc[900],
padding: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.zinc[800],
marginTop: theme.spacing[2],
},
metadataTitle: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
},
metadataRow: {
flexDirection: "row",
marginBottom: theme.spacing[1],
},
metadataLabel: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.xs,
width: 80,
},
metadataValue: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.xs,
flex: 1,
fontFamily: "monospace",
},
}));
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
useEffect(() => {
if (!artifact) return;
console.log('[ArtifactDrawer] Showing artifact:', artifact.id, artifact.type, artifact.title);
console.log(
"[ArtifactDrawer] Showing artifact:",
artifact.id,
artifact.type,
artifact.title
);
}, [artifact]);
if (!artifact) {
@@ -28,12 +170,12 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
// Decode content if base64
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
// Type badge colors
const typeColors = {
markdown: 'bg-blue-600',
diff: 'bg-purple-600',
image: 'bg-green-600',
code: 'bg-orange-600',
// Type badge style mapping
const typeBadgeStyles = {
markdown: styles.badgeMarkdown,
diff: styles.badgeDiff,
image: styles.badgeImage,
code: styles.badgeCode,
};
return (
@@ -43,84 +185,81 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView edges={['top', 'bottom']} className="flex-1 bg-black">
<SafeAreaView edges={["top", "bottom"]} style={styles.container}>
{/* Header */}
<View className="pb-4 px-4 border-b border-zinc-800">
<View className="flex-row items-center justify-between">
<View className="flex-1 mr-4">
<Text className="text-white text-xl font-bold" numberOfLines={2}>
<View style={styles.header}>
<View style={styles.headerRow}>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View className="flex-row items-center gap-2">
<View className={`${typeColors[artifact.type]} px-3 py-1 rounded-full`}>
<Text className="text-white text-xs font-semibold uppercase">
{artifact.type}
<View style={styles.headerActions}>
<View
style={[
styles.badge,
typeBadgeStyles[artifact.type],
]}
>
<Text style={styles.badgeText}>
{artifact.type.toUpperCase()}
</Text>
</View>
<Pressable
onPress={onClose}
className="bg-zinc-800 w-10 h-10 rounded-full items-center justify-center"
>
<Text className="text-white text-xl font-bold">×</Text>
<Pressable onPress={onClose} style={styles.closeButton}>
<Text style={styles.closeButtonText}>×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView className="flex-1" contentContainerClassName="p-4">
{artifact.type === 'image' ? (
<View className="items-center justify-center">
<Text className="text-zinc-400 text-sm">
<ScrollView
style={styles.contentScroll}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
<View style={styles.imagePlaceholder}>
<Text style={styles.imagePlaceholderText}>
Image viewing not yet implemented
</Text>
<Text className="text-zinc-600 text-xs mt-2">
<Text style={styles.imagePlaceholderSubtext}>
Base64 image data received
</Text>
</View>
) : (
<View className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<Text
className="text-zinc-300 text-sm font-mono"
style={{ fontFamily: 'monospace' }}
>
{content}
</Text>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
)}
</ScrollView>
{/* Metadata */}
<View className="mt-4 bg-zinc-900 rounded-lg p-3 border border-zinc-800">
<Text className="text-zinc-500 text-xs font-semibold mb-2">METADATA</Text>
<View className="space-y-1">
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">ID:</Text>
<Text className="text-zinc-400 text-xs flex-1 font-mono">
{artifact.id}
</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Type:</Text>
<Text className="text-zinc-400 text-xs">{artifact.type}</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Encoding:</Text>
<Text className="text-zinc-400 text-xs">
{artifact.isBase64 ? 'Base64' : 'Plain text'}
</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Size:</Text>
<Text className="text-zinc-400 text-xs">
{content.length.toLocaleString()} characters
</Text>
</View>
{/* Metadata - Fixed at bottom */}
<View style={styles.metadataContainer}>
<Text style={styles.metadataTitle}>METADATA</Text>
<View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>ID:</Text>
<Text style={styles.metadataValue}>{artifact.id}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Type:</Text>
<Text style={styles.metadataValue}>{artifact.type}</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Encoding:</Text>
<Text style={styles.metadataValue}>
{artifact.isBase64 ? "Base64" : "Plain text"}
</Text>
</View>
<View style={styles.metadataRow}>
<Text style={styles.metadataLabel}>Size:</Text>
<Text style={styles.metadataValue}>
{content.length.toLocaleString()} characters
</Text>
</View>
</View>
</ScrollView>
</View>
</SafeAreaView>
</Modal>
);

View File

@@ -1,15 +1,50 @@
import { View, Text } from 'react-native';
import { StyleSheet } from 'react-native-unistyles';
interface ConnectionStatusProps {
isConnected: boolean;
}
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.zinc[800],
},
row: {
flexDirection: 'row',
alignItems: 'center',
},
dot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
marginRight: theme.spacing[2],
},
dotConnected: {
backgroundColor: theme.colors.green[500],
},
dotDisconnected: {
backgroundColor: theme.colors.red[500],
},
text: {
fontSize: theme.fontSize.sm,
},
textConnected: {
color: theme.colors.green[500],
},
textDisconnected: {
color: theme.colors.red[500],
},
}));
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View className="px-4 py-3 border-b border-zinc-800">
<View className="flex-row items-center">
<View className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<Text className={isConnected ? 'text-green-500 text-sm' : 'text-red-500 text-sm'}>
<View style={styles.container}>
<View style={styles.row}>
<View style={[styles.dot, isConnected ? styles.dotConnected : styles.dotDisconnected]} />
<Text style={[styles.text, isConnected ? styles.textConnected : styles.textDisconnected]}>
{isConnected ? 'Connected' : 'Disconnected'}
</Text>
</View>

View File

@@ -3,6 +3,7 @@ import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView,
import { SafeAreaView } from 'react-native-safe-area-context';
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StyleSheet } from 'react-native-unistyles';
import type { UseWebSocketReturn } from '../hooks/use-websocket';
const STORAGE_KEY = '@voice-dev:conversation-id';
@@ -177,7 +178,7 @@ export function ConversationSelector({
<View>
<Pressable
onPress={() => setIsOpen(true)}
className="bg-zinc-800 px-4 py-2 rounded-lg"
style={styles.triggerButton}
>
<MessageSquare size={20} color="white" />
</Pressable>
@@ -188,99 +189,94 @@ export function ConversationSelector({
transparent={true}
onRequestClose={() => setIsOpen(false)}
>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}>
<View style={styles.modalOverlay}>
<Pressable
style={{ flex: 1 }}
style={styles.modalBackdrop}
onPress={() => setIsOpen(false)}
/>
<View style={{ backgroundColor: '#18181b', borderTopLeftRadius: 24, borderTopRightRadius: 24, height: '80%' }}>
<View style={{ flex: 1 }}>
<View style={styles.modalContent}>
<View style={styles.modalInner}>
{/* Header */}
<View className="flex-row items-center justify-between p-6 border-b border-zinc-800">
<Text className="text-white text-lg font-semibold">
Conversations {conversations.length > 0 && `(${conversations.length})`}
</Text>
<Pressable onPress={() => setIsOpen(false)}>
<X size={24} color="white" />
</Pressable>
</View>
<View style={styles.header}>
<Text style={styles.headerTitle}>
Conversations {conversations.length > 0 && `(${conversations.length})`}
</Text>
<Pressable onPress={() => setIsOpen(false)}>
<X size={24} color="white" />
</Pressable>
</View>
{/* New Conversation Button */}
<View className="p-4 border-b border-zinc-800">
<Pressable
onPress={handleNewConversation}
className="flex-row items-center justify-center gap-2 bg-zinc-800 py-3 rounded-lg"
>
<Plus size={20} color="white" />
<Text className="text-white font-semibold">New Conversation</Text>
</Pressable>
</View>
{/* Conversations List */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 20 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{isLoading && (
<View className="p-8 items-center">
<Text className="text-zinc-400">Loading...</Text>
</View>
)}
{!isLoading && conversations.length === 0 && (
<View className="p-8 items-center">
<Text className="text-zinc-400">No saved conversations</Text>
</View>
)}
{!isLoading && conversations.map((conversation) => (
<View
key={conversation.id}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#27272a',
backgroundColor: conversation.id === currentConversationId ? '#27272a' : 'transparent'
}}
{/* New Conversation Button */}
<View style={styles.newButtonContainer}>
<Pressable
onPress={handleNewConversation}
style={styles.newButton}
>
<Pressable
style={{ flex: 1 }}
onPress={() => handleSelectConversation(conversation.id)}
>
<Text style={{ color: 'white', fontWeight: '600', fontSize: 16 }}>
{conversation.messageCount} messages
</Text>
<Text style={{ color: '#a1a1aa', fontSize: 14, marginTop: 4 }}>
{formatDate(conversation.lastUpdated)}
</Text>
</Pressable>
<Pressable
onPress={() => handleDeleteConversation(conversation.id)}
style={{ padding: 8 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Trash2 size={20} color="#ef4444" />
</Pressable>
</View>
))}
<Plus size={20} color="white" />
<Text style={styles.newButtonText}>New Conversation</Text>
</Pressable>
</View>
{!isLoading && conversations.length > 0 && (
<View className="p-4">
<Pressable
onPress={handleClearAll}
className="flex-row items-center justify-center gap-2 bg-red-900/20 py-3 rounded-lg border border-red-900"
{/* Conversations List */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{isLoading && (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
)}
{!isLoading && conversations.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No saved conversations</Text>
</View>
)}
{!isLoading && conversations.map((conversation) => (
<View
key={conversation.id}
style={[
styles.conversationItem,
conversation.id === currentConversationId && styles.conversationItemActive
]}
>
<Trash2 size={18} color="#ef4444" />
<Text className="text-red-500 font-semibold">Clear All</Text>
</Pressable>
</View>
)}
</ScrollView>
<Pressable
style={styles.conversationContent}
onPress={() => handleSelectConversation(conversation.id)}
>
<Text style={styles.conversationTitle}>
{conversation.messageCount} messages
</Text>
<Text style={styles.conversationDate}>
{formatDate(conversation.lastUpdated)}
</Text>
</Pressable>
<Pressable
onPress={() => handleDeleteConversation(conversation.id)}
style={styles.deleteButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Trash2 size={20} color="#ef4444" />
</Pressable>
</View>
))}
{!isLoading && conversations.length > 0 && (
<View style={styles.clearAllContainer}>
<Pressable
onPress={handleClearAll}
style={styles.clearAllButton}
>
<Trash2 size={18} color="#ef4444" />
<Text style={styles.clearAllText}>Clear All</Text>
</Pressable>
</View>
)}
</ScrollView>
</View>
</View>
</View>
@@ -288,3 +284,125 @@ export function ConversationSelector({
</View>
);
}
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
triggerButton: {
backgroundColor: theme.colors.zinc[800],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
},
modalBackdrop: {
flex: 1,
},
modalContent: {
backgroundColor: theme.colors.zinc[900],
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
height: '80%',
},
modalInner: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[6],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.zinc[800],
},
headerTitle: {
color: theme.colors.white,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
newButtonContainer: {
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.zinc[800],
},
newButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[2],
backgroundColor: theme.colors.zinc[800],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newButtonText: {
color: theme.colors.white,
fontWeight: theme.fontWeight.semibold,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: theme.spacing[4] * 5,
},
loadingContainer: {
padding: theme.spacing[8],
alignItems: 'center',
},
loadingText: {
color: theme.colors.zinc[400],
},
emptyContainer: {
padding: theme.spacing[8],
alignItems: 'center',
},
emptyText: {
color: theme.colors.zinc[400],
},
conversationItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing[4],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.zinc[800],
backgroundColor: 'transparent',
},
conversationItemActive: {
backgroundColor: theme.colors.zinc[800],
},
conversationContent: {
flex: 1,
},
conversationTitle: {
color: theme.colors.white,
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.base,
},
conversationDate: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
deleteButton: {
padding: theme.spacing[2],
},
clearAllContainer: {
padding: theme.spacing[4],
},
clearAllButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: theme.spacing[2],
backgroundColor: 'rgba(127, 29, 29, 0.2)',
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.red[900],
},
clearAllText: {
color: theme.colors.red[500],
fontWeight: theme.fontWeight.semibold,
},
}));

View File

@@ -2,17 +2,40 @@ import { View, Text, Pressable, Animated } from 'react-native';
import { useState, useEffect, useRef } from 'react';
import Markdown from 'react-native-markdown-display';
import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native';
import { StyleSheet } from 'react-native-unistyles';
interface UserMessageProps {
message: string;
timestamp: number;
}
const userMessageStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
bubble: {
backgroundColor: theme.colors.blue[600],
borderRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
maxWidth: '80%',
},
text: {
color: theme.colors.white,
fontSize: theme.fontSize.lg,
lineHeight: 24,
},
}));
export function UserMessage({ message, timestamp }: UserMessageProps) {
return (
<View className="flex-row justify-end mb-3 px-4">
<View className="bg-blue-600 rounded-2xl rounded-tr-sm px-4 py-3 max-w-[80%]">
<Text className="text-white text-base leading-6">{message}</Text>
<View style={userMessageStylesheet.container}>
<View style={userMessageStylesheet.bubble}>
<Text style={userMessageStylesheet.text}>{message}</Text>
</View>
</View>
);
@@ -24,6 +47,22 @@ interface AssistantMessageProps {
isStreaming?: boolean;
}
const assistantMessageStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
marginBottom: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
streamingIndicator: {
marginTop: theme.spacing[1],
},
streamingText: {
color: theme.colors.teal[200],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.bold,
},
}));
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
const fadeAnim = useRef(new Animated.Value(0.3)).current;
@@ -60,10 +99,10 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As
marginBottom: 8,
},
strong: {
fontWeight: '700',
fontWeight: '700' as const,
},
em: {
fontStyle: 'italic',
fontStyle: 'italic' as const,
},
code_inline: {
backgroundColor: '#134e4a',
@@ -91,7 +130,7 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As
},
link: {
color: '#5eead4',
textDecorationLine: 'underline',
textDecorationLine: 'underline' as const,
},
bullet_list: {
marginBottom: 8,
@@ -105,11 +144,11 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As
};
return (
<View className="mb-3 px-4 py-3">
<View style={assistantMessageStylesheet.container}>
<Markdown style={markdownStyles}>{message}</Markdown>
{isStreaming && (
<Animated.View style={{ opacity: fadeAnim }} className="mt-1">
<Text className="text-teal-200 text-xs font-bold">...</Text>
<Animated.View style={[assistantMessageStylesheet.streamingIndicator, { opacity: fadeAnim }]}>
<Text style={assistantMessageStylesheet.streamingText}>...</Text>
</Animated.View>
)}
</View>
@@ -127,6 +166,76 @@ interface ActivityLogProps {
onArtifactClick?: (artifactId: string) => void;
}
const activityLogStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[1],
borderRadius: theme.borderRadius.md,
overflow: 'hidden',
},
pressableActive: {
opacity: 0.7,
},
systemBg: {
backgroundColor: 'rgba(39, 39, 42, 0.5)', // zinc-800/50
},
infoBg: {
backgroundColor: 'rgba(30, 58, 138, 0.3)', // blue-900/30
},
successBg: {
backgroundColor: 'rgba(20, 83, 45, 0.3)', // green-900/30
},
errorBg: {
backgroundColor: 'rgba(127, 29, 29, 0.3)', // red-900/30
},
artifactBg: {
backgroundColor: 'rgba(30, 58, 138, 0.4)', // blue-900/40
},
content: {
paddingHorizontal: theme.spacing[3],
paddingVertical: 10,
},
row: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: theme.spacing[2],
},
iconContainer: {
flexShrink: 0,
},
textContainer: {
flex: 1,
},
messageText: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
detailsRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: theme.spacing[1],
},
detailsText: {
color: theme.colors.zinc[500],
fontSize: theme.fontSize.xs,
marginRight: theme.spacing[1],
},
metadataContainer: {
marginTop: theme.spacing[2],
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: theme.borderRadius.base,
padding: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.zinc[700],
},
metadataText: {
color: theme.colors.zinc[300],
fontSize: theme.fontSize.xs,
fontFamily: 'monospace',
lineHeight: 16,
},
}));
export function ActivityLog({
type,
message,
@@ -140,11 +249,11 @@ export function ActivityLog({
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
system: { bg: 'bg-zinc-800/50', color: '#a1a1aa', Icon: Circle },
info: { bg: 'bg-blue-900/30', color: '#60a5fa', Icon: Info },
success: { bg: 'bg-green-900/30', color: '#4ade80', Icon: CheckCircle },
error: { bg: 'bg-red-900/30', color: '#f87171', Icon: XCircle },
artifact: { bg: 'bg-blue-900/40', color: '#93c5fd', Icon: FileText },
system: { bg: activityLogStylesheet.systemBg, color: '#a1a1aa', Icon: Circle },
info: { bg: activityLogStylesheet.infoBg, color: '#60a5fa', Icon: Info },
success: { bg: activityLogStylesheet.successBg, color: '#4ade80', Icon: CheckCircle },
error: { bg: activityLogStylesheet.errorBg, color: '#f87171', Icon: XCircle },
artifact: { bg: activityLogStylesheet.artifactBg, color: '#93c5fd', Icon: FileText },
};
const config = typeConfig[type];
@@ -162,24 +271,30 @@ export function ActivityLog({
? `${artifactType}: ${title}`
: message;
const isInteractive = type === 'artifact' || metadata;
return (
<Pressable
onPress={handlePress}
disabled={type !== 'artifact' && !metadata}
className={`mx-2 mb-1 rounded-md overflow-hidden ${config.bg} ${
(type === 'artifact' || metadata) ? 'active:opacity-70' : ''
}`}
disabled={!isInteractive}
style={[
activityLogStylesheet.pressable,
config.bg,
isInteractive && activityLogStylesheet.pressableActive,
]}
>
<View className="px-3 py-2.5">
<View className="flex-row items-start gap-2">
<IconComponent size={16} color={config.color} />
<View className="flex-1">
<Text style={{ color: config.color }} className="text-sm leading-5">
<View style={activityLogStylesheet.content}>
<View style={activityLogStylesheet.row}>
<View style={activityLogStylesheet.iconContainer}>
<IconComponent size={16} color={config.color} />
</View>
<View style={activityLogStylesheet.textContainer}>
<Text style={[activityLogStylesheet.messageText, { color: config.color }]}>
{displayMessage}
</Text>
{metadata && (
<View className="flex-row items-center mt-1">
<Text className="text-zinc-500 text-xs mr-1">Details</Text>
<View style={activityLogStylesheet.detailsRow}>
<Text style={activityLogStylesheet.detailsText}>Details</Text>
{isExpanded ? (
<ChevronDown size={12} color="#71717a" />
) : (
@@ -190,8 +305,8 @@ export function ActivityLog({
</View>
</View>
{isExpanded && metadata && (
<View className="mt-2 bg-black/50 rounded p-2 border border-zinc-700">
<Text className="text-zinc-300 text-xs font-mono leading-4">
<View style={activityLogStylesheet.metadataContainer}>
<Text style={activityLogStylesheet.metadataText}>
{JSON.stringify(metadata, null, 2)}
</Text>
</View>
@@ -209,6 +324,102 @@ interface ToolCallProps {
status: 'executing' | 'completed' | 'failed';
}
const toolCallStylesheet = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
pressable: {
marginHorizontal: theme.spacing[2],
marginBottom: theme.spacing[2],
backgroundColor: theme.colors.zinc[900],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
overflow: 'hidden',
},
pressableActive: {
opacity: 0.8,
},
executingBorder: {
borderColor: theme.colors.amber[500],
},
completedBorder: {
borderColor: theme.colors.green[500],
},
failedBorder: {
borderColor: theme.colors.red[500],
},
content: {
padding: theme.spacing[3],
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
},
chevronContainer: {
marginRight: theme.spacing[2],
},
toolName: {
color: theme.colors.slate[200],
fontFamily: 'monospace',
fontWeight: theme.fontWeight.semibold,
fontSize: theme.fontSize.sm,
flex: 1,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
executingBadgeBg: {
backgroundColor: 'rgba(245, 158, 11, 0.2)', // amber-500/20
},
completedBadgeBg: {
backgroundColor: 'rgba(34, 197, 94, 0.2)', // green-500/20
},
failedBadgeBg: {
backgroundColor: 'rgba(239, 68, 68, 0.2)', // red-500/20
},
statusText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: 'uppercase',
},
expandedContent: {
marginTop: theme.spacing[3],
gap: theme.spacing[2],
},
section: {
// empty - just for grouping
},
sectionTitle: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
errorSectionTitle: {
color: '#fca5a5', // red-300
},
sectionContent: {
backgroundColor: theme.colors.black,
borderRadius: theme.borderRadius.base,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.zinc[700],
padding: theme.spacing[2],
},
errorSectionContent: {
borderColor: '#991b1b', // red-800
},
sectionText: {
color: theme.colors.slate[200],
fontSize: theme.fontSize.xs,
fontFamily: 'monospace',
lineHeight: 16,
},
}));
export function ToolCall({ toolName, args, result, error, status }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
@@ -235,20 +446,20 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
const statusConfig = {
executing: {
border: 'border-amber-500',
bg: 'bg-amber-500/20',
border: toolCallStylesheet.executingBorder,
badgeBg: toolCallStylesheet.executingBadgeBg,
color: '#fcd34d',
label: 'executing',
},
completed: {
border: 'border-green-500',
bg: 'bg-green-500/20',
border: toolCallStylesheet.completedBorder,
badgeBg: toolCallStylesheet.completedBadgeBg,
color: '#86efac',
label: 'completed',
},
failed: {
border: 'border-red-500',
bg: 'bg-red-500/20',
border: toolCallStylesheet.failedBorder,
badgeBg: toolCallStylesheet.failedBadgeBg,
color: '#fca5a5',
label: 'failed',
},
@@ -259,19 +470,21 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
return (
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className={`mx-2 mb-2 bg-zinc-900 rounded-lg border ${config.border} overflow-hidden active:opacity-80`}
style={[toolCallStylesheet.pressable, toolCallStylesheet.pressableActive, config.border]}
>
<View className="p-3">
<View className="flex-row items-center">
{isExpanded ? (
<ChevronDown size={16} color="#9ca3af" className="mr-2" />
) : (
<ChevronRight size={16} color="#9ca3af" className="mr-2" />
)}
<Text className="text-slate-200 font-mono font-medium text-sm flex-1">
<View style={toolCallStylesheet.content}>
<View style={toolCallStylesheet.headerRow}>
<View style={toolCallStylesheet.chevronContainer}>
{isExpanded ? (
<ChevronDown size={16} color="#9ca3af" />
) : (
<ChevronRight size={16} color="#9ca3af" />
)}
</View>
<Text style={toolCallStylesheet.toolName}>
{toolName}
</Text>
<View className={`flex-row items-center gap-1.5 px-2 py-1 rounded ${config.bg}`}>
<View style={[toolCallStylesheet.statusBadge, config.badgeBg]}>
{status === 'executing' ? (
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={14} color={config.color} />
@@ -281,32 +494,32 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
) : (
<XCircle size={14} color={config.color} />
)}
<Text style={{ color: config.color }} className="text-xs font-medium uppercase">
<Text style={[toolCallStylesheet.statusText, { color: config.color }]}>
{config.label}
</Text>
</View>
</View>
{isExpanded && (
<View className="mt-3 gap-2">
<View>
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
<View style={toolCallStylesheet.expandedContent}>
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
Arguments
</Text>
<View className="bg-black rounded border border-zinc-700 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(args, null, 2)}
</Text>
</View>
</View>
{result !== undefined && (
<View>
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
<View style={toolCallStylesheet.section}>
<Text style={toolCallStylesheet.sectionTitle}>
Result
</Text>
<View className="bg-black rounded border border-zinc-700 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
<View style={toolCallStylesheet.sectionContent}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(result, null, 2)}
</Text>
</View>
@@ -314,12 +527,12 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp
)}
{error !== undefined && (
<View>
<Text className="text-red-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
<View style={toolCallStylesheet.section}>
<Text style={[toolCallStylesheet.sectionTitle, toolCallStylesheet.errorSectionTitle]}>
Error
</Text>
<View className="bg-black rounded border border-red-800 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
<View style={[toolCallStylesheet.sectionContent, toolCallStylesheet.errorSectionContent]}>
<Text style={toolCallStylesheet.sectionText}>
{JSON.stringify(error, null, 2)}
</Text>
</View>

View File

@@ -1,5 +1,6 @@
import { Pressable, View, Text, Animated } from 'react-native';
import { useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native-unistyles';
interface VoiceButtonProps {
state: 'idle' | 'recording' | 'processing' | 'playing';
@@ -7,6 +8,125 @@ interface VoiceButtonProps {
disabled?: boolean;
}
const styles = StyleSheet.create((theme: import('../styles/theme').Theme) => ({
container: {
alignItems: 'center',
gap: theme.spacing[4],
},
pressable: {
opacity: 1,
},
pressableDisabled: {
opacity: 0.5,
},
button: {
width: 80,
height: 80,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
buttonIdle: {
backgroundColor: theme.colors.zinc[700],
},
buttonRecording: {
backgroundColor: theme.colors.red[500],
},
buttonProcessing: {
backgroundColor: theme.colors.blue[500],
},
buttonPlaying: {
backgroundColor: theme.colors.green[500],
},
label: {
color: theme.colors.zinc[400],
fontSize: theme.fontSize.sm,
},
// Recording icon
recordingIcon: {
width: 24,
height: 24,
backgroundColor: '#fff',
borderRadius: theme.borderRadius.md,
},
// Processing icon
processingIconContainer: {
width: 24,
height: 24,
},
processingDot: {
width: 6,
height: 6,
backgroundColor: '#fff',
borderRadius: theme.borderRadius.full,
position: 'absolute',
},
processingDotTop: {
top: 0,
left: 12,
},
processingDotRight: {
top: 12,
right: 0,
},
processingDotBottom: {
bottom: 0,
left: 12,
},
// Playing icon
playingIconContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
},
playingBar: {
backgroundColor: '#fff',
borderRadius: theme.borderRadius.sm,
},
playingBar1: {
width: 4,
height: 16,
},
playingBar2: {
width: 4,
height: 24,
},
playingBar3: {
width: 4,
height: 12,
},
// Idle icon (microphone)
micContainer: {
width: 24,
height: 32,
position: 'relative',
},
micCapsule: {
position: 'absolute',
bottom: 0,
left: 4,
width: 16,
height: 24,
backgroundColor: '#fff',
borderTopLeftRadius: 999,
borderTopRightRadius: 999,
},
micBase: {
position: 'absolute',
bottom: 0,
left: 0,
width: 24,
height: 6,
backgroundColor: '#fff',
borderRadius: theme.borderRadius.full,
},
}));
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
const pulseAnim = useRef(new Animated.Value(1)).current;
@@ -34,43 +154,41 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
const getButtonStyle = () => {
switch (state) {
case 'recording':
return 'bg-red-500';
return styles.buttonRecording;
case 'processing':
return 'bg-blue-500';
return styles.buttonProcessing;
case 'playing':
return 'bg-green-500';
return styles.buttonPlaying;
default:
return 'bg-zinc-700';
return styles.buttonIdle;
}
};
const getIcon = () => {
switch (state) {
case 'recording':
return (
<View className="w-6 h-6 bg-white rounded" />
);
return <View style={styles.recordingIcon} />;
case 'processing':
return (
<View className="w-6 h-6">
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-0 left-3" />
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-3 right-0" />
<View className="w-1.5 h-1.5 bg-white rounded-full absolute bottom-0 left-3" />
<View style={styles.processingIconContainer}>
<View style={[styles.processingDot, styles.processingDotTop]} />
<View style={[styles.processingDot, styles.processingDotRight]} />
<View style={[styles.processingDot, styles.processingDotBottom]} />
</View>
);
case 'playing':
return (
<View className="flex-row items-center gap-1">
<View className="w-1 h-4 bg-white rounded" />
<View className="w-1 h-6 bg-white rounded" />
<View className="w-1 h-3 bg-white rounded" />
<View style={styles.playingIconContainer}>
<View style={[styles.playingBar, styles.playingBar1]} />
<View style={[styles.playingBar, styles.playingBar2]} />
<View style={[styles.playingBar, styles.playingBar3]} />
</View>
);
default:
return (
<View className="w-6 h-8 relative">
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-6 bg-white rounded-t-full" />
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-1.5 bg-white rounded-full" />
<View style={styles.micContainer}>
<View style={styles.micCapsule} />
<View style={styles.micBase} />
</View>
);
}
@@ -90,20 +208,17 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro
};
return (
<View className="items-center gap-4">
<View style={styles.container}>
<Pressable
onPress={onPress}
disabled={disabled}
className={`${disabled ? 'opacity-50' : 'opacity-100'}`}
style={disabled ? styles.pressableDisabled : styles.pressable}
>
<Animated.View
style={{ transform: [{ scale: pulseAnim }] }}
className={`w-20 h-20 rounded-full ${getButtonStyle()} items-center justify-center shadow-lg`}
>
<Animated.View style={[styles.button, getButtonStyle(), { transform: [{ scale: pulseAnim }] }]}>
{getIcon()}
</Animated.View>
</Pressable>
<Text className="text-zinc-400 text-sm">{getLabel()}</Text>
<Text style={styles.label}>{getLabel()}</Text>
</View>
);
}

View File

@@ -1,4 +0,0 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";

View File

@@ -1,7 +1,5 @@
import { useAudioPlayer as useExpoAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { Paths, File } from 'expo-file-system';
import { useState, useRef, useEffect } from 'react';
import { Platform } from 'react-native';
import { initialize, playPCMData } from '@speechmatics/expo-two-way-audio';
interface QueuedAudio {
audioData: Blob;
@@ -9,75 +7,57 @@ interface QueuedAudio {
reject: (error: Error) => void;
}
interface AudioPlayerOptions {
useSpeaker?: boolean;
/**
* Resample PCM16 audio from 24kHz to 16kHz
* OpenAI returns 24kHz, Speechmatics expects 16kHz
*/
function resamplePcm24kTo16k(pcm24k: Uint8Array): Uint8Array {
// PCM16 = 2 bytes per sample
const samples24k = pcm24k.length / 2;
const samples16k = Math.floor(samples24k * 16000 / 24000);
const pcm16k = new Uint8Array(samples16k * 2);
const ratio = 24000 / 16000; // 1.5
for (let i = 0; i < samples16k; i++) {
const srcIndex = Math.floor(i * ratio) * 2;
if (srcIndex + 1 < pcm24k.length) {
pcm16k[i * 2] = pcm24k[srcIndex];
pcm16k[i * 2 + 1] = pcm24k[srcIndex + 1];
}
}
console.log('[AudioPlayer] Resampled PCM:', {
input24k: pcm24k.length,
output16k: pcm16k.length,
durationMs: (samples16k / 16),
});
return pcm16k;
}
/**
* Convert Blob to file URI for expo-audio playback
* Hook for audio playback using Speechmatics two-way audio with echo cancellation
*/
async function blobToFile(blob: Blob): Promise<File> {
// Read blob as array buffer
const arrayBuffer = await blob.arrayBuffer();
// Create temporary file in cache directory
const fileName = `audio_${Date.now()}.mp3`;
const file = new File(Paths.cache, fileName);
// Write array buffer to file
const uint8Array = new Uint8Array(arrayBuffer);
file.create();
const stream = file.writableStream();
const writer = stream.getWriter();
await writer.write(uint8Array);
await writer.close();
return file;
}
/**
* Hook for audio playback with queue system matching web version functionality
*/
export function useAudioPlayer(options?: AudioPlayerOptions) {
const player = useExpoAudioPlayer();
export function useAudioPlayer() {
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const queueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false);
const currentRejectRef = useRef<((error: Error) => void) | null>(null);
const currentFileRef = useRef<File | null>(null);
// Configure audio mode based on speaker/earpiece preference
// Initialize Speechmatics audio on mount
useEffect(() => {
async function configureAudioMode() {
const initializeAudio = async () => {
try {
if (Platform.OS === 'android') {
// On Android, use shouldRouteThroughEarpiece
await setAudioModeAsync({
shouldRouteThroughEarpiece: !options?.useSpeaker,
});
} else if (Platform.OS === 'ios') {
// On iOS, the audio routing is controlled by the AVAudioSession category
// The default category should work, but we can configure it if needed
// For now, we rely on the iOS default behavior which routes to the receiver
// unless the user has changed the route (e.g., via speaker button in Control Center)
}
await initialize();
setAudioInitialized(true);
console.log('[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)');
} catch (error) {
console.warn('[AudioPlayer] Failed to configure audio mode:', error);
console.error('[AudioPlayer] Failed to initialize audio:', error);
}
}
configureAudioMode();
}, [options?.useSpeaker]);
// Monitor playback status
useEffect(() => {
if (!player.playing && isPlaying && !isPaused) {
// Playback finished
setIsPlaying(false);
processNextInQueue();
}
}, [player.playing, isPlaying, isPaused]);
};
initializeAudio();
}, []);
async function play(audioData: Blob): Promise<number> {
return new Promise((resolve, reject) => {
@@ -127,92 +107,55 @@ export function useAudioPlayer(options?: AudioPlayerOptions) {
async function playAudio(audioData: Blob): Promise<number> {
return new Promise(async (resolve, reject) => {
currentRejectRef.current = reject;
try {
console.log(
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
);
// Convert blob to file
const file = await blobToFile(audioData);
currentFileRef.current = file;
if (!audioInitialized) {
throw new Error('Audio not initialized');
}
// Get PCM data from blob (server now sends PCM format)
const arrayBuffer = await audioData.arrayBuffer();
let pcm24k = new Uint8Array(arrayBuffer);
// Resample from 24kHz (OpenAI) to 16kHz (Speechmatics)
const pcm16k = resamplePcm24kTo16k(pcm24k);
// Calculate total duration
const samples = pcm16k.length / 2; // 16-bit = 2 bytes per sample
const durationSec = samples / 16000; // 16kHz sample rate
const audioSizeKb = (pcm16k.length / 1024).toFixed(2);
console.log('[AudioPlayer] 🔊 Playing audio:', audioSizeKb, 'KB, duration:', durationSec.toFixed(2), 's');
setIsPlaying(true);
setIsPaused(false);
// Load and play audio
await player.replace({ uri: file.uri });
player.play();
// Play entire PCM data at once through Speechmatics
playPCMData(pcm16k);
// Wait for playback to finish
const checkInterval = setInterval(() => {
if (!player.playing && player.duration > 0) {
clearInterval(checkInterval);
const duration = player.duration;
console.log(`[AudioPlayer] Playback finished (duration: ${duration}s)`);
setIsPlaying(false);
currentRejectRef.current = null;
// Clean up temporary file
if (currentFileRef.current) {
try {
currentFileRef.current.delete();
} catch (err: any) {
console.warn('[AudioPlayer] Failed to delete temp file:', err);
}
currentFileRef.current = null;
}
resolve(duration);
}
}, 100);
// Wait for playback to finish (estimate based on duration)
setTimeout(() => {
console.log('[AudioPlayer] ✅ Playback finished');
setIsPlaying(false);
resolve(durationSec);
}, durationSec * 1000);
} catch (error) {
console.error('[AudioPlayer] Error playing audio:', error);
setIsPlaying(false);
currentRejectRef.current = null;
// Clean up temporary file
if (currentFileRef.current) {
try {
currentFileRef.current.delete();
} catch (err: any) {
console.warn('[AudioPlayer] Failed to delete temp file:', err);
}
currentFileRef.current = null;
}
reject(error);
}
});
}
function stop(): void {
// Stop currently playing audio
if (player) {
player.pause();
if (isPlaying) {
console.log('[AudioPlayer] 🛑 Stopping playback (interrupted)');
}
setIsPlaying(false);
setIsPaused(false);
// Clean up temporary file
if (currentFileRef.current) {
try {
currentFileRef.current.delete();
} catch (err: any) {
console.warn('[AudioPlayer] Failed to delete temp file:', err);
}
currentFileRef.current = null;
}
// Reject the current playing promise if it exists
if (currentRejectRef.current) {
currentRejectRef.current(new Error('Playback stopped'));
currentRejectRef.current = null;
}
// Reject all pending promises in the queue
while (queueRef.current.length > 0) {
@@ -223,22 +166,6 @@ export function useAudioPlayer(options?: AudioPlayerOptions) {
isProcessingQueueRef.current = false;
}
function pause(): void {
if (player && isPlaying && !isPaused) {
player.pause();
setIsPaused(true);
console.log('[AudioPlayer] Playback paused');
}
}
function resume(): void {
if (player && isPaused) {
player.play();
setIsPaused(false);
console.log('[AudioPlayer] Playback resumed');
}
}
function clearQueue(): void {
// Reject all pending promises in the queue
while (queueRef.current.length > 0) {
@@ -250,10 +177,7 @@ export function useAudioPlayer(options?: AudioPlayerOptions) {
return {
play,
stop,
pause,
resume,
isPlaying: () => isPlaying,
isPaused: () => isPaused,
clearQueue,
};
}

View File

@@ -1,4 +1,4 @@
import { useAudioRecorder as useExpoAudioRecorder, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { Paths, File, Directory, FileInfo } from 'expo-file-system';
import { useState, useEffect } from 'react';
@@ -159,24 +159,25 @@ export function useAudioRecorder(config?: AudioCaptureConfig) {
};
const audioRecorder = useExpoAudioRecorder(recordingOptions);
const recorderState = useAudioRecorderState(audioRecorder, 100);
// Monitor audio levels if metering is enabled
useEffect(() => {
if (!config?.onAudioLevel || !isRecording) return;
const interval = setInterval(() => {
const metering = audioRecorder.state?.metering;
const metering = recorderState.metering;
if (metering !== undefined && metering !== null) {
// Normalize metering value (typically ranges from -160 to 0 dB)
// Convert to 0-1 range where 0 is silence and 1 is loud
// We'll use -40 dB as the threshold for "loud"
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
config.onAudioLevel(normalized);
config.onAudioLevel?.(normalized);
}
}, 100); // Check every 100ms
return () => clearInterval(interval);
}, [isRecording, config, audioRecorder.state?.metering]);
}, [isRecording, config, recorderState.metering]);
async function start(): Promise<void> {
if (isRecording) {

View File

@@ -10,6 +10,7 @@ export interface RealtimeAudioConfig {
onError?: (error: Error) => void;
volumeThreshold?: number; // RMS threshold for speech detection (0-1)
silenceDuration?: number; // ms of silence before ending segment
speechStartDuration?: number; // ms of sustained volume before starting segment
}
export interface RealtimeAudio {
@@ -19,6 +20,115 @@ export interface RealtimeAudio {
isSpeaking: boolean;
}
/**
* Convert raw PCM data to WAV format by adding WAV headers
* @param pcmBase64 Base64-encoded PCM data
* @param sampleRate Sample rate (default: 16000)
* @param channels Number of channels (default: 1 for mono)
* @param bitsPerSample Bits per sample (default: 16)
* @returns Base64-encoded WAV data
*/
function pcmToWav(
pcmBase64: string,
sampleRate: number = 16000,
channels: number = 1,
bitsPerSample: number = 16
): string {
// Decode base64 PCM data
const pcmBinary = atob(pcmBase64);
const pcmLength = pcmBinary.length;
// Calculate WAV parameters
const byteRate = sampleRate * channels * (bitsPerSample / 8);
const blockAlign = channels * (bitsPerSample / 8);
const dataSize = pcmLength;
const fileSize = 44 + dataSize; // WAV header is 44 bytes
// Create WAV buffer
const wavBuffer = new Uint8Array(fileSize);
const view = new DataView(wavBuffer.buffer);
// Write WAV header
let offset = 0;
// RIFF chunk descriptor
writeString(view, offset, 'RIFF'); offset += 4;
view.setUint32(offset, fileSize - 8, true); offset += 4; // File size - 8
writeString(view, offset, 'WAVE'); offset += 4;
// fmt sub-chunk
writeString(view, offset, 'fmt '); offset += 4;
view.setUint32(offset, 16, true); offset += 4; // Subchunk size (16 for PCM)
view.setUint16(offset, 1, true); offset += 2; // Audio format (1 = PCM)
view.setUint16(offset, channels, true); offset += 2;
view.setUint32(offset, sampleRate, true); offset += 4;
view.setUint32(offset, byteRate, true); offset += 4;
view.setUint16(offset, blockAlign, true); offset += 2;
view.setUint16(offset, bitsPerSample, true); offset += 2;
// data sub-chunk
writeString(view, offset, 'data'); offset += 4;
view.setUint32(offset, dataSize, true); offset += 4;
// Copy PCM data
for (let i = 0; i < pcmLength; i++) {
wavBuffer[offset + i] = pcmBinary.charCodeAt(i);
}
// Convert to base64
let binary = '';
for (let i = 0; i < wavBuffer.length; i++) {
binary += String.fromCharCode(wavBuffer[i]);
}
return btoa(binary);
}
/**
* Helper to write string to DataView
*/
function writeString(view: DataView, offset: number, str: string): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
/**
* Decode base64 string to Uint8Array
*/
function decodeBase64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Encode Uint8Array to base64 string
*/
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Concatenate multiple Uint8Arrays into one
*/
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Hook for realtime audio streaming with volume-based VAD
* Uses expo-audio-studio for recording with echo cancellation and noise suppression
@@ -28,13 +138,17 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
const [isSpeaking, setIsSpeaking] = useState(false);
const audioRecorder = useAudioRecorder();
const audioBufferRef = useRef<string[]>([]);
const audioBufferRef = useRef<Uint8Array[]>([]);
const silenceStartRef = useRef<number | null>(null);
const isSpeakingRef = useRef(false);
const speechStartRef = useRef<number | null>(null); // Track when volume first exceeded threshold
const speechConfirmedRef = useRef(false); // Track if speech has been confirmed
const lastVolumeCheckRef = useRef<number>(Date.now());
const lastVolumeLogRef = useRef<number>(Date.now());
const VOLUME_THRESHOLD = config.volumeThreshold ?? 0.02;
const VOLUME_THRESHOLD = config.volumeThreshold ?? 0.2;
const SILENCE_DURATION_MS = config.silenceDuration ?? 1000;
const SPEECH_START_DURATION_MS = config.speechStartDuration ?? 1000;
const VOLUME_CHECK_INTERVAL = 200; // Check volume every 200ms
async function requestMicrophonePermission(): Promise<boolean> {
@@ -84,7 +198,6 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
}
const rms = Math.sqrt(sum / sampleCount);
console.log('[RealtimeAudio] Volume:', rms.toFixed(6));
return rms;
} catch (error) {
console.error('[RealtimeAudio] Volume calculation error:', error);
@@ -126,38 +239,84 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
const volume = calculateVolume(audioData);
const speechDetected = volume > VOLUME_THRESHOLD;
if (speechDetected && !isSpeakingRef.current) {
// Speech START
console.log('[RealtimeAudio] Speech started (volume:', volume.toFixed(4), ')');
isSpeakingRef.current = true;
// Decode base64 chunk to binary for buffering
const decodedChunk = decodeBase64ToUint8Array(audioData);
// Check if we're in detection phase (volume detected but not yet confirmed)
const inDetectionPhase = speechStartRef.current !== null && !isSpeakingRef.current;
if (inDetectionPhase) {
// We're in detection phase - check timer regardless of current volume
const sustainedDuration = Date.now() - speechStartRef.current!;
if (sustainedDuration >= SPEECH_START_DURATION_MS) {
// Timer elapsed - confirm speech
console.log('[RealtimeAudio] ✅ Speech confirmed after', sustainedDuration, 'ms (volume:', volume.toFixed(4), 'threshold:', VOLUME_THRESHOLD, ')');
isSpeakingRef.current = true;
speechConfirmedRef.current = true;
setIsSpeaking(true);
config.onSpeechStart?.();
audioBufferRef.current.push(decodedChunk);
silenceStartRef.current = null;
} else if (speechDetected) {
// Still in detection phase, volume still above threshold, keep buffering
audioBufferRef.current.push(decodedChunk);
// Log progress every 500ms during detection
const logNow = Date.now();
if (logNow - lastVolumeLogRef.current >= 500) {
console.log('[RealtimeAudio] 🔍 Detecting...', sustainedDuration, '/', SPEECH_START_DURATION_MS, 'ms (volume:', volume.toFixed(4), ')');
lastVolumeLogRef.current = logNow;
}
} else {
// Volume dropped before timer elapsed - false start
console.log('[RealtimeAudio] ❌ False start (volume dropped after', sustainedDuration, 'ms, needed', SPEECH_START_DURATION_MS, 'ms)');
speechStartRef.current = null;
speechConfirmedRef.current = false;
audioBufferRef.current = [];
}
} else if (speechDetected && !isSpeakingRef.current) {
// First volume detection - start timer and buffering
console.log('[RealtimeAudio] 🎤 Volume spike detected! Starting detection... (volume:', volume.toFixed(4), 'threshold:', VOLUME_THRESHOLD, ')');
speechStartRef.current = Date.now();
speechConfirmedRef.current = false;
audioBufferRef.current = [decodedChunk];
silenceStartRef.current = null;
audioBufferRef.current = [audioData];
setIsSpeaking(true);
config.onSpeechStart?.();
} else if (speechDetected && isSpeakingRef.current) {
// Continuing speech
audioBufferRef.current.push(audioData);
// Continuing confirmed speech - just buffer, no logs
audioBufferRef.current.push(decodedChunk);
silenceStartRef.current = null;
} else if (!speechDetected && isSpeakingRef.current) {
// Potential speech END
audioBufferRef.current.push(audioData);
// Potential speech END (after confirmed speech)
audioBufferRef.current.push(decodedChunk);
if (silenceStartRef.current === null) {
silenceStartRef.current = Date.now();
console.log('[RealtimeAudio] 🔇 Silence detected, waiting', SILENCE_DURATION_MS, 'ms to confirm end...');
} else {
const silenceDuration = Date.now() - silenceStartRef.current;
if (silenceDuration >= SILENCE_DURATION_MS) {
// Speech END confirmed
console.log('[RealtimeAudio] Speech ended after', silenceDuration, 'ms silence');
const totalChunks = audioBufferRef.current.length;
console.log('[RealtimeAudio] 🛑 Speech ended after', silenceDuration, 'ms silence (captured', totalChunks, 'chunks)');
isSpeakingRef.current = false;
speechStartRef.current = null;
speechConfirmedRef.current = false;
silenceStartRef.current = null;
setIsSpeaking(false);
config.onSpeechEnd?.();
// Send buffered audio segment
if (audioBufferRef.current.length > 0) {
const combinedAudio = audioBufferRef.current.join('');
config.onAudioSegment?.(combinedAudio);
// Concatenate all binary chunks
const combinedBinary = concatenateUint8Arrays(audioBufferRef.current);
const audioSizeKb = (combinedBinary.length / 1024).toFixed(2);
console.log('[RealtimeAudio] 📤 Sending audio segment:', audioSizeKb, 'KB');
// Convert to base64
const combinedPcmBase64 = uint8ArrayToBase64(combinedBinary);
// Convert PCM to WAV format
const wavAudio = pcmToWav(combinedPcmBase64, 16000, 1, 16);
config.onAudioSegment?.(wavAudio);
audioBufferRef.current = [];
}
}
@@ -165,13 +324,14 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
}
} else if (isSpeakingRef.current) {
// Just buffer if we're speaking but not checking volume yet
audioBufferRef.current.push(audioData);
const decodedChunk = decodeBase64ToUint8Array(audioData);
audioBufferRef.current.push(decodedChunk);
}
},
});
setIsActive(true);
console.log('[RealtimeAudio] Realtime audio started successfully');
console.log('[RealtimeAudio] 🎙️ Audio capture started (threshold:', VOLUME_THRESHOLD, 'duration:', SPEECH_START_DURATION_MS, 'ms)');
} catch (error) {
console.error('[RealtimeAudio] Start error:', error);
const err = error instanceof Error ? error : new Error(String(error));
@@ -198,6 +358,8 @@ export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
// Reset state
audioBufferRef.current = [];
isSpeakingRef.current = false;
speechStartRef.current = null;
speechConfirmedRef.current = false;
silenceStartRef.current = null;
setIsActive(false);
setIsSpeaking(false);

View File

@@ -0,0 +1,352 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
initialize,
toggleRecording,
tearDown,
useExpoTwoWayAudioEventListener,
type MicrophoneDataCallback,
type VolumeLevelCallback,
} from "@speechmatics/expo-two-way-audio";
export interface SpeechmaticsAudioConfig {
onAudioSegment?: (audioData: string) => void;
onSpeechStart?: () => void;
onSpeechEnd?: () => void;
onError?: (error: Error) => void;
volumeThreshold: number; // Volume threshold for speech detection (0-1)
silenceDuration: number; // ms of silence before ending segment
speechConfirmationDuration: number; // ms of sustained speech before confirming
detectionGracePeriod: number; // ms grace period for volume dips during detection
}
export interface SpeechmaticsAudio {
start: () => Promise<void>;
stop: () => Promise<void>;
isActive: boolean;
isSpeaking: boolean;
isDetecting: boolean;
}
/**
* Convert raw PCM data to WAV format by adding WAV headers
*/
function pcmToWav(
pcmBase64: string,
sampleRate: number = 16000,
channels: number = 1,
bitsPerSample: number = 16
): string {
const pcmBinary = atob(pcmBase64);
const pcmLength = pcmBinary.length;
const byteRate = sampleRate * channels * (bitsPerSample / 8);
const blockAlign = channels * (bitsPerSample / 8);
const dataSize = pcmLength;
const fileSize = 44 + dataSize;
const wavBuffer = new Uint8Array(fileSize);
const view = new DataView(wavBuffer.buffer);
let offset = 0;
// RIFF chunk descriptor
writeString(view, offset, "RIFF");
offset += 4;
view.setUint32(offset, fileSize - 8, true);
offset += 4;
writeString(view, offset, "WAVE");
offset += 4;
// fmt sub-chunk
writeString(view, offset, "fmt ");
offset += 4;
view.setUint32(offset, 16, true);
offset += 4;
view.setUint16(offset, 1, true);
offset += 2;
view.setUint16(offset, channels, true);
offset += 2;
view.setUint32(offset, sampleRate, true);
offset += 4;
view.setUint32(offset, byteRate, true);
offset += 4;
view.setUint16(offset, blockAlign, true);
offset += 2;
view.setUint16(offset, bitsPerSample, true);
offset += 2;
// data sub-chunk
writeString(view, offset, "data");
offset += 4;
view.setUint32(offset, dataSize, true);
offset += 4;
// Copy PCM data
for (let i = 0; i < pcmLength; i++) {
wavBuffer[offset + i] = pcmBinary.charCodeAt(i);
}
// Convert to base64
let binary = "";
for (let i = 0; i < wavBuffer.length; i++) {
binary += String.fromCharCode(wavBuffer[i]);
}
return btoa(binary);
}
function writeString(view: DataView, offset: number, str: string): void {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
}
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function concatenateUint8Arrays(arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Hook for audio capture with echo cancellation using Speechmatics expo-two-way-audio
*/
export function useSpeechmaticsAudio(
config: SpeechmaticsAudioConfig
): SpeechmaticsAudio {
const [isActive, setIsActive] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isDetecting, setIsDetecting] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false);
const audioBufferRef = useRef<Uint8Array[]>([]);
const silenceStartRef = useRef<number | null>(null);
const isSpeakingRef = useRef(false);
const speechDetectionStartRef = useRef<number | null>(null);
const speechConfirmedRef = useRef(false);
const detectionSilenceStartRef = useRef<number | null>(null);
const VOLUME_THRESHOLD = config.volumeThreshold;
const SILENCE_DURATION_MS = config.silenceDuration;
const SPEECH_CONFIRMATION_MS = config.speechConfirmationDuration;
const DETECTION_GRACE_PERIOD_MS = config.detectionGracePeriod;
// Initialize audio on mount
useEffect(() => {
const initializeAudio = async () => {
try {
await initialize();
setAudioInitialized(true);
console.log("[SpeechmaticsAudio] Audio initialized");
} catch (error) {
console.error("[SpeechmaticsAudio] Initialization error:", error);
const err = error instanceof Error ? error : new Error(String(error));
config.onError?.(err);
}
};
initializeAudio();
return () => {
tearDown();
};
}, []);
// Listen to microphone data
useExpoTwoWayAudioEventListener(
"onMicrophoneData",
useCallback<MicrophoneDataCallback>(
(event) => {
if (!isActive) return;
const pcmData: Uint8Array = event.data;
// Buffer the audio chunk if we're detecting or speaking
// Start buffering from first spike to capture beginning of speech
if (speechDetectionStartRef.current !== null || isSpeakingRef.current) {
audioBufferRef.current.push(pcmData);
}
},
[isActive]
)
);
// Listen to volume level for VAD
useExpoTwoWayAudioEventListener(
"onInputVolumeLevelData",
useCallback<VolumeLevelCallback>(
(event) => {
if (!isActive) return;
const volume: number = event.data;
const speechDetected = volume > VOLUME_THRESHOLD;
// console.log('[SpeechmaticsAudio] Volume:', volume.toFixed(6));
if (speechDetected && !isSpeakingRef.current && !speechConfirmedRef.current) {
// Initial speech detection - start tracking
if (speechDetectionStartRef.current === null) {
console.log(
"[SpeechmaticsAudio] Speech started (volume:",
volume.toFixed(4),
")"
);
speechDetectionStartRef.current = Date.now();
detectionSilenceStartRef.current = null;
audioBufferRef.current = [];
setIsDetecting(true);
} else {
// Volume is back above threshold - reset grace period
detectionSilenceStartRef.current = null;
// Check if speech has been sustained long enough
const speechDuration = Date.now() - speechDetectionStartRef.current;
if (speechDuration >= SPEECH_CONFIRMATION_MS) {
// Speech CONFIRMED
console.log(
"[SpeechmaticsAudio] Speech confirmed after",
speechDuration,
"ms"
);
isSpeakingRef.current = true;
speechConfirmedRef.current = true;
silenceStartRef.current = null;
setIsDetecting(false);
setIsSpeaking(true);
config.onSpeechStart?.();
}
}
} else if (speechDetected && isSpeakingRef.current && speechConfirmedRef.current) {
// Continuing confirmed speech
silenceStartRef.current = null;
} else if (!speechDetected && !speechConfirmedRef.current && speechDetectionStartRef.current !== null) {
// Volume dropped during detection phase - apply grace period
if (detectionSilenceStartRef.current === null) {
detectionSilenceStartRef.current = Date.now();
} else {
const graceDuration = Date.now() - detectionSilenceStartRef.current;
if (graceDuration >= DETECTION_GRACE_PERIOD_MS) {
// Grace period expired - cancel detection
console.log(
"[SpeechmaticsAudio] Speech detection cancelled after",
graceDuration,
"ms grace period"
);
speechDetectionStartRef.current = null;
detectionSilenceStartRef.current = null;
audioBufferRef.current = [];
setIsDetecting(false);
}
}
} else if (!speechDetected && isSpeakingRef.current && speechConfirmedRef.current) {
// Potential speech END
if (silenceStartRef.current === null) {
silenceStartRef.current = Date.now();
} else {
const silenceDuration = Date.now() - silenceStartRef.current;
if (silenceDuration >= SILENCE_DURATION_MS) {
// Speech END confirmed
console.log(
"[SpeechmaticsAudio] Speech ended after",
silenceDuration,
"ms silence"
);
isSpeakingRef.current = false;
speechConfirmedRef.current = false;
speechDetectionStartRef.current = null;
silenceStartRef.current = null;
setIsSpeaking(false);
config.onSpeechEnd?.();
// Send buffered audio segment
if (audioBufferRef.current.length > 0) {
const combinedBinary = concatenateUint8Arrays(
audioBufferRef.current
);
const combinedPcmBase64 = uint8ArrayToBase64(combinedBinary);
const wavAudio = pcmToWav(combinedPcmBase64, 16000, 1, 16);
config.onAudioSegment?.(wavAudio);
audioBufferRef.current = [];
}
}
}
}
},
[isActive, VOLUME_THRESHOLD, SILENCE_DURATION_MS, SPEECH_CONFIRMATION_MS, DETECTION_GRACE_PERIOD_MS, config]
)
);
async function start(): Promise<void> {
if (!audioInitialized) {
throw new Error("Audio not initialized");
}
if (isActive) {
console.log("[SpeechmaticsAudio] Already active");
return;
}
try {
console.log("[SpeechmaticsAudio] Starting audio capture...");
// Start recording
toggleRecording(true);
setIsActive(true);
console.log("[SpeechmaticsAudio] Audio capture started successfully");
} catch (error) {
console.error("[SpeechmaticsAudio] Start error:", error);
const err = error instanceof Error ? error : new Error(String(error));
config.onError?.(err);
await stop();
throw error;
}
}
async function stop(): Promise<void> {
console.log("[SpeechmaticsAudio] Stopping audio capture...");
// Stop recording
toggleRecording(false);
// Reset state
audioBufferRef.current = [];
isSpeakingRef.current = false;
speechConfirmedRef.current = false;
speechDetectionStartRef.current = null;
detectionSilenceStartRef.current = null;
silenceStartRef.current = null;
setIsActive(false);
setIsSpeaking(false);
setIsDetecting(false);
console.log("[SpeechmaticsAudio] Audio capture stopped");
}
// Cleanup on unmount
useEffect(() => {
return () => {
if (isActive) {
stop();
}
};
}, [isActive]);
return {
start,
stop,
isActive,
isSpeaking,
isDetecting,
};
}

View File

@@ -58,7 +58,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
// Only session messages trigger handlers
if (wsMessage.type === 'session') {
const sessionMessage = wsMessage.message;
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
// console.log(`[WS] Received session message type: ${sessionMessage.type}`);
// Track conversation ID when loaded
if (sessionMessage.type === 'conversation_loaded') {

View File

@@ -1,6 +1,5 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativewind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativewind(config);
module.exports = config;

View File

@@ -17,6 +17,8 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@siteed/expo-audio-studio": "^2.18.1",
"@speechmatics/expo-two-way-audio": "^0.1.2",
"buffer": "^6.0.3",
"expo": "^54.0.18",
"expo-audio": "~1.0.13",
"expo-av": "^16.0.7",
@@ -37,7 +39,6 @@
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"nativewind": "^5.0.0-preview.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
@@ -49,18 +50,16 @@
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.14.0",
"react-native-unistyles": "^3.0.15",
"react-native-web": "~0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.15",
"@types/react": "~19.2.0",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"postcss": "^8.4.49",
"tailwindcss": "^4.1.15",
"typescript": "~5.9.2"
},
"overrides": {

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,153 @@
export const theme = {
colors: {
// Base colors
white: "#ffffff",
black: "#000000",
// Zinc scale (primary gray palette)
zinc: {
50: "#fafafa",
100: "#f4f4f5",
200: "#e4e4e7",
300: "#d4d4d8",
400: "#a1a1aa",
500: "#71717a",
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
900: "#18181b",
},
// Gray scale
gray: {
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
},
// Slate scale
slate: {
200: "#e2e8f0",
},
// Blue scale
blue: {
50: "#eff6ff",
100: "#dbeafe",
200: "#bfdbfe",
300: "#93c5fd",
400: "#60a5fa",
500: "#3b82f6",
600: "#2563eb",
700: "#1d4ed8",
800: "#1e40af",
900: "#1e3a8a",
950: "#172554",
},
// Green scale
green: {
100: "#dcfce7",
200: "#bbf7d0",
500: "#22c55e",
600: "#16a34a",
800: "#166534",
900: "#14532d",
},
// Red scale
red: {
100: "#fee2e2",
200: "#fecaca",
500: "#ef4444",
600: "#dc2626",
800: "#991b1b",
900: "#7f1d1d",
},
// Teal scale
teal: {
200: "#99f6e4",
},
// Amber scale
amber: {
500: "#f59e0b",
},
// Purple scale
purple: {
500: "#a855f7",
600: "#9333ea",
},
// Orange scale
orange: {
500: "#f97316",
600: "#ea580c",
},
},
spacing: {
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
12: 48,
16: 64,
20: 80,
24: 96,
32: 128,
},
fontSize: {
xs: 10,
sm: 12,
base: 14,
lg: 16,
xl: 18,
"2xl": 20,
"3xl": 24,
},
fontWeight: {
normal: "normal" as const,
semibold: "600" as const,
bold: "bold" as const,
},
borderRadius: {
none: 0,
sm: 2,
base: 4,
md: 6,
lg: 8,
xl: 12,
"2xl": 16,
full: 9999,
},
borderWidth: {
0: 0,
1: 1,
2: 2,
},
opacity: {
0: 0,
50: 0.5,
100: 1,
},
} as const;
export type Theme = typeof theme;

View File

@@ -0,0 +1,21 @@
import { StyleSheet } from "react-native-unistyles";
import { theme } from "./theme";
// Configure Unistyles with our theme
StyleSheet.configure({
themes: {
dark: theme,
},
settings: {
initialTheme: "dark",
},
});
// Type augmentation for TypeScript
type AppThemes = {
dark: typeof theme;
};
declare module "react-native-unistyles" {
export interface UnistylesThemes extends AppThemes {}
}