import { useCallback, useEffect, useMemo, useState } from 'react' import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller' import { deleteDictationModel, downloadDictationModel, fetchDictationSetup, isModelInFlight, setDictationConfig, type MobileSpeechModel, type MobileSpeechSetup } from '../src/dictation/mobile-dictation-setup' const POLL_INTERVAL_MS = 1500 const DICTATION_MODES = [ { value: 'toggle', label: 'Toggle' }, { value: 'hold', label: 'Hold' } ] as const type ModelBusyAction = { modelId: string; type: 'download' | 'select' | 'delete' } export default function VoiceSettingsScreen(): React.JSX.Element { const router = useRouter() const insets = useSafeAreaInsets() const [hosts, setHosts] = useState([]) useEffect(() => { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds) // Voice dictation runs on the paired desktop, so pick the first connected host. const client: RpcClient | null = useMemo( () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null, [hostClients] ) const [setup, setSetup] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) const refresh = useCallback(async (): Promise => { if (!client) { return false } try { const next = await fetchDictationSetup(client) setSetup(next) setError(null) return next.models.some(isModelInFlight) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load voice settings') return undefined } finally { setLoading(false) } }, [client]) const polling = setup?.models.some(isModelInFlight) ?? false const refreshSetup = useDictationSetupPoller({ visible: routeFocused && client !== null, polling, refresh, intervalMs: POLL_INTERVAL_MS }) useEffect(() => { if (routeFocused && client && setup === null) { setLoading(true) } }, [routeFocused, client, setup]) const handleToggleEnabled = useCallback( async (enabled: boolean) => { if (!client) { return } setError(null) // Optimistic flip so the switch responds instantly; reconcile below. setSetup((prev) => (prev ? { ...prev, enabled } : prev)) try { setSetup(await setDictationConfig(client, { enabled })) } catch (err) { setError(err instanceof Error ? err.message : 'Could not update') void refreshSetup() } }, [client, refreshSetup] ) const handleSelectMode = useCallback( async (dictationMode: 'toggle' | 'hold') => { if (!client) { return } setError(null) setSetup((prev) => (prev ? { ...prev, dictationMode } : prev)) try { setSetup(await setDictationConfig(client, { dictationMode })) } catch (err) { setError(err instanceof Error ? err.message : 'Could not update') void refreshSetup() } }, [client, refreshSetup] ) const handleUseModel = useCallback( async (model: MobileSpeechModel) => { if (!client) { return } setBusyAction({ modelId: model.id, type: 'select' }) setError(null) try { setSetup(await setDictationConfig(client, { enabled: true, modelId: model.id })) setModelDrawerOpen(false) } catch (err) { setError(err instanceof Error ? err.message : 'Could not select model') } finally { setBusyAction(null) } }, [client] ) const handleDownload = useCallback( async (model: MobileSpeechModel) => { if (!client) { return } setBusyAction({ modelId: model.id, type: 'download' }) setError(null) try { await downloadDictationModel(client, model.id) await refreshSetup() } catch (err) { setError(err instanceof Error ? err.message : 'Download failed') } finally { setBusyAction(null) } }, [client, refreshSetup] ) const handleDelete = useCallback( async (model: MobileSpeechModel) => { if (!client) { return } const deletedSelectedModel = setup?.selectedModelId === model.id setBusyAction({ modelId: model.id, type: 'delete' }) setError(null) try { setSetup(await deleteDictationModel(client, model.id)) if (deletedSelectedModel) { setModelDrawerOpen(false) } } catch (err) { setError(err instanceof Error ? err.message : 'Delete failed') } finally { setBusyAction(null) } }, [client, setup?.selectedModelId] ) const enabled = setup?.enabled ?? false const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId) const selectedModelLabel = selectedModel?.label ?? 'None selected' return ( router.back()}> Voice {!client ? ( Connect to a desktop to manage voice settings. ) : loading && setup === null ? ( ) : setup === null ? ( {error ?? 'Failed to load voice settings.'} ) : ( DICTATION Enable Voice Dictation Dictate text into any focused pane on your desktop. void handleToggleEnabled(v)} trackColor={{ false: colors.bgRaised, true: colors.textSecondary }} thumbColor={colors.textPrimary} /> Dictation Mode Toggle: press once to start, again to stop. Hold: dictate while held. {DICTATION_MODES.map((mode) => { const active = setup.dictationMode === mode.value return ( void handleSelectMode(mode.value)} style={[styles.segment, active && styles.segmentActive]} > {mode.label} ) })} SPEECH MODEL [ styles.row, !enabled && styles.disabled, pressed && styles.rowPressed ]} disabled={!enabled} onPress={() => setModelDrawerOpen(true)} > Speech Model {selectedModelLabel} {error ? {error} : null} )} setModelDrawerOpen(false)}> Speech Model {setup ? ( void handleUseModel(m)} onDownload={(m) => void handleDownload(m)} onDelete={(m) => void handleDelete(m)} /> ) : null} ) } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase, paddingHorizontal: spacing.lg }, topRow: { flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm, marginBottom: spacing.lg }, backButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', marginRight: spacing.sm }, heading: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, scrollContent: { paddingBottom: spacing.xl }, loading: { paddingVertical: spacing.xl, alignItems: 'center' }, groupHeading: { fontSize: 11, fontWeight: '600', color: colors.textMuted, letterSpacing: 0.5, marginBottom: spacing.xs, paddingHorizontal: spacing.xs }, section: { backgroundColor: colors.bgPanel, borderRadius: radii.card, overflow: 'hidden' }, sectionTopGap: { marginTop: spacing.sm }, inputGroupGap: { marginTop: spacing.xl }, disabled: { opacity: 0.5 }, emptyText: { fontSize: typography.bodySize, color: colors.textSecondary, padding: spacing.md }, errorText: { fontSize: typography.bodySize, color: colors.statusRed, padding: spacing.md }, row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2, paddingVertical: spacing.md, paddingHorizontal: spacing.md + 2 }, rowPressed: { backgroundColor: colors.bgRaised }, rowContent: { flex: 1 }, rowLabel: { fontSize: typography.bodySize, fontWeight: '500', color: colors.textPrimary }, drawerTitle: { fontSize: typography.bodySize, fontWeight: '700', color: colors.textPrimary, paddingHorizontal: spacing.md + 2, paddingTop: spacing.sm, paddingBottom: spacing.xs }, rowSublabel: { fontSize: typography.bodySize - 2, color: colors.textSecondary, marginTop: 2 }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: colors.borderSubtle, marginHorizontal: spacing.md }, segmented: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.bgBase, borderRadius: radii.button, padding: 2 }, segment: { paddingHorizontal: spacing.md, paddingVertical: 6, borderRadius: radii.button - 1 }, segmentActive: { backgroundColor: colors.bgRaised }, segmentText: { fontSize: typography.metaSize, color: colors.textSecondary, fontWeight: '600' }, segmentTextActive: { color: colors.textPrimary }, error: { color: colors.statusRed, fontSize: typography.metaSize, marginTop: spacing.md } })