diff --git a/__tests__/SettingsScreen.test.tsx b/__tests__/SettingsScreen.test.tsx new file mode 100644 index 0000000..d30d7d2 --- /dev/null +++ b/__tests__/SettingsScreen.test.tsx @@ -0,0 +1,172 @@ +import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; +import SettingsScreen from '../app/settings/index'; +import * as api from '../services/lmStudioApi'; +import { DEFAULT_SERVER_CONFIG } from '../services/types'; +import { useAppStore } from '../store/useAppStore'; + +jest.mock('../services/lmStudioApi'); + +let mockSystemScheme: 'light' | 'dark' = 'light'; +jest.mock('../hooks/useColorScheme', () => ({ + useColorScheme: () => mockSystemScheme, +})); + +const mockApi = api as jest.Mocked; +const state = () => useAppStore.getState(); + +const hostInput = () => screen.getByPlaceholderText('localhost'); +const portInput = () => screen.getByPlaceholderText('1234'); +const keyInput = () => screen.getByPlaceholderText('Leave empty for local'); + +beforeEach(() => { + jest.resetAllMocks(); + mockSystemScheme = 'light'; + useAppStore.setState(useAppStore.getInitialState(), true); + mockApi.getModels.mockResolvedValue([]); +}); + +describe('SettingsScreen connection form', () => { + it('is filled from the stored configuration and hides "Save Changes" until something changes', () => { + useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } }); + render(); + + expect(hostInput().props.value).toBe('192.168.0.5'); + expect(portInput().props.value).toBe('4321'); + expect(keyInput().props.value).toBe('k'); + expect(screen.queryByText('Save Changes')).toBeNull(); + }); + + it('shows "Save Changes" for each changed field', () => { + render(); + + fireEvent.changeText(hostInput(), 'other'); + expect(screen.getByText('Save Changes')).toBeTruthy(); + fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host); + expect(screen.queryByText('Save Changes')).toBeNull(); + + fireEvent.changeText(portInput(), '999'); + expect(screen.getByText('Save Changes')).toBeTruthy(); + fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port)); + expect(screen.queryByText('Save Changes')).toBeNull(); + + fireEvent.changeText(keyInput(), 'secret'); + expect(screen.getByText('Save Changes')).toBeTruthy(); + }); + + it('saves trimmed values', () => { + render(); + fireEvent.changeText(hostInput(), ' 192.168.0.9 '); + fireEvent.changeText(portInput(), '8080'); + fireEvent.changeText(keyInput(), ' key '); + fireEvent.press(screen.getByText('Save Changes')); + + expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' }); + }); + + it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => { + useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } }); + render(); + fireEvent.changeText(hostInput(), ' '); + fireEvent.changeText(portInput(), 'abc'); + fireEvent.changeText(keyInput(), ' '); + fireEvent.press(screen.getByText('Save Changes')); + + expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined }); + }); +}); + +describe('SettingsScreen connection test', () => { + it('saves the configuration, reports success and loads the models', async () => { + mockApi.testConnection.mockResolvedValue(true); + render(); + fireEvent.changeText(hostInput(), '10.0.0.2'); + + await act(async () => { + fireEvent.press(screen.getByText('Test Connection')); + }); + + expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined }); + expect(state().serverConfig.host).toBe('10.0.0.2'); + expect(state().isConnected).toBe(true); + expect(mockApi.getModels).toHaveBeenCalledTimes(1); + expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy(); + expect(screen.getByText('Connected')).toBeTruthy(); + }); + + it('reports failure and does not load models', async () => { + mockApi.testConnection.mockResolvedValue(false); + render(); + + await act(async () => { + fireEvent.press(screen.getByText('Test Connection')); + }); + + expect(state().isConnected).toBe(false); + expect(mockApi.getModels).not.toHaveBeenCalled(); + expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy(); + expect(screen.getByText('Disconnected')).toBeTruthy(); + }); + + it('shows a spinner and ignores presses while the test is running', async () => { + let resolveTest: (ok: boolean) => void = () => {}; + mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve))); + render(); + + await act(async () => { + fireEvent.press(screen.getByText('Test Connection')); + }); + expect(screen.queryByText('Test Connection')).toBeNull(); + + await act(async () => { + resolveTest(false); + }); + expect(screen.getByText('Test Connection')).toBeTruthy(); + }); + + it('clears a previous result when the configuration is saved again', async () => { + mockApi.testConnection.mockResolvedValue(false); + render(); + await act(async () => { + fireEvent.press(screen.getByText('Test Connection')); + }); + expect(screen.getByText(/Connection failed/)).toBeTruthy(); + + fireEvent.changeText(hostInput(), 'changed'); + fireEvent.press(screen.getByText('Save Changes')); + expect(screen.queryByText(/Connection failed/)).toBeNull(); + }); +}); + +describe('SettingsScreen dark mode switch', () => { + const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch); + + it('is on for the dark preference and switches to light', () => { + useAppStore.setState({ theme: 'dark' }); + render(); + expect(darkSwitch().props.value).toBe(true); + + fireEvent(darkSwitch(), 'valueChange', false); + expect(state().theme).toBe('light'); + }); + + it('is off for the light preference and switches to dark', () => { + useAppStore.setState({ theme: 'light' }); + render(); + expect(darkSwitch().props.value).toBe(false); + + fireEvent(darkSwitch(), 'valueChange', true); + expect(state().theme).toBe('dark'); + }); + + it('follows the system scheme when the preference is "system"', () => { + mockSystemScheme = 'dark'; + const first = render(); + expect(darkSwitch().props.value).toBe(true); + first.unmount(); + + mockSystemScheme = 'light'; + render(); + expect(darkSwitch().props.value).toBe(false); + }); +}); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts new file mode 100644 index 0000000..b92528e --- /dev/null +++ b/__tests__/utils.test.ts @@ -0,0 +1,77 @@ +import { ChatMessage } from '../store/useAppStore'; +import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors'; +import { buildServerConfig, isConfigChanged } from '../utils/serverConfig'; +import { isDarkTheme } from '../utils/theme'; + +const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({ + id, + role, + content, + timestamp: 0, +}); + +describe('findLastAssistantId', () => { + it('returns the most recent assistant message', () => { + expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3'); + }); + + it('returns null when there is no assistant message', () => { + expect(findLastAssistantId([msg('1', 'user')])).toBeNull(); + expect(findLastAssistantId([])).toBeNull(); + }); +}); + +describe('shouldShowTypingIndicator', () => { + it('is true only while streaming an assistant message with no text yet', () => { + expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true); + }); + + it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => { + expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false); + expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false); + expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false); + expect(shouldShowTypingIndicator([], true)).toBe(false); + }); +}); + +describe('buildServerConfig', () => { + it('trims the host and API key and parses the port', () => { + expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({ + host: '10.0.0.1', + port: 8080, + apiKey: 'k', + }); + }); + + it('uses the defaults for blank host, invalid port and blank key', () => { + expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({ + host: 'localhost', + port: 1234, + apiKey: undefined, + }); + }); +}); + +describe('isConfigChanged', () => { + const saved = { host: 'lan', port: 1234, apiKey: 'k' }; + + it('is false when the form matches the saved config', () => { + expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false); + expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false); + }); + + it('is true when any single field differs', () => { + expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true); + expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true); + expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true); + }); +}); + +describe('isDarkTheme', () => { + it('honours an explicit preference and falls back to the system scheme', () => { + expect(isDarkTheme('dark', 'light')).toBe(true); + expect(isDarkTheme('light', 'dark')).toBe(false); + expect(isDarkTheme('system', 'dark')).toBe(true); + expect(isDarkTheme('system', 'light')).toBe(false); + }); +}); diff --git a/app/settings/index.tsx b/app/settings/index.tsx index 3a9e692..064e3ee 100644 --- a/app/settings/index.tsx +++ b/app/settings/index.tsx @@ -1,76 +1,14 @@ -import { ConnectionBadge } from '@/components/ui/ConnectionBadge'; -import { useLMStudio } from '@/hooks/useLMStudio'; +import { AboutCard } from '@/components/settings/AboutCard'; +import { AppearanceCard } from '@/components/settings/AppearanceCard'; +import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard'; import { useThemeColor } from '@/hooks/useThemeColor'; -import * as api from '@/services/lmStudioApi'; -import { ServerConfig } from '@/services/types'; -import { useAppStore } from '@/store/useAppStore'; -import { Info, MonitorCog, Moon, Network } from 'lucide-react-native'; -import React, { useState } from 'react'; -import { - ActivityIndicator, - ScrollView, - StyleSheet, - Switch, - Text, - TextInput, - TouchableOpacity, - useWindowDimensions, - View, -} from 'react-native'; +import React from 'react'; +import { ScrollView, StyleSheet, useWindowDimensions } from 'react-native'; export default function SettingsScreen() { const { width } = useWindowDimensions(); const colors = useThemeColor(); // Get current theme colors - - // Global State - const themePreference = useAppStore(state => state.theme); - const setTheme = useAppStore(state => state.setTheme); - const serverConfig = useAppStore(state => state.serverConfig); - const setServerConfig = useAppStore(state => state.setServerConfig); - const isConnected = useAppStore(state => state.isConnected); - const setIsConnected = useAppStore(state => state.setIsConnected); - const { fetchModels } = useLMStudio(); - - // Local State - const [host, setHost] = useState(serverConfig.host); - const [port, setPort] = useState(serverConfig.port.toString()); - const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? ''); - const [isTesting, setIsTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - const isLargeScreen = width > 768; - const isDarkMode = themePreference === 'dark' || (themePreference === 'system' && colors.backgroundColor === '#09090B'); // Hacky check but robust given our Colors.ts - const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark'); - - const currentConfig: ServerConfig = { - host: host.trim() || 'localhost', - port: parseInt(port, 10) || 1234, - apiKey: apiKey.trim() || undefined, - }; - - const handleSaveConfig = () => { - setServerConfig(currentConfig); - setTestResult(null); - }; - - const handleTestConnection = async () => { - handleSaveConfig(); - setIsTesting(true); - setTestResult(null); - - const ok = await api.testConnection(currentConfig); - setTestResult(ok); - setIsConnected(ok); - if (ok) { - await fetchModels(); - } - setIsTesting(false); - }; - - const configChanged = - host !== serverConfig.host || - port !== serverConfig.port.toString() || - apiKey !== (serverConfig.apiKey ?? ''); return ( - {/* Server Connection Section */} - - SERVER CONNECTION - - - - - - LM Studio API - - - - - - - - Host IP / Address - - - - - Port Number - - - - - API Key (optional) - - - - - {configChanged && ( - - Save Changes - - )} - - {isTesting ? ( - - ) : ( - Test Connection - )} - - - - {testResult !== null && ( - - - {testResult - ? 'Connection successful. API is ready.' - : 'Connection failed. Verify host and port.'} - - - )} - - - - {/* Appearance Section */} - - APPEARANCE - - - - - - - Dark Mode - Toggle sleek dark theme - - - - - - - {/* About Section */} - - ABOUT - - - - LocalMind - Cross Platform Version - - - - - Connected via OpenAPI `/v1/chat/completions` specs - - - - + + + ); } @@ -241,130 +37,4 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingTop: 40, }, - section: { - marginBottom: 24, - }, - desktopSection: { - width: '100%', - maxWidth: 600, - }, - sectionLabel: { - fontSize: 13, - fontWeight: '600', - marginLeft: 16, - marginBottom: 8, - letterSpacing: 0.5, - }, - card: { - borderRadius: 16, - padding: 16, - borderWidth: 1, - }, - cardHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 16, - }, - cardHeaderTitle: { - flexDirection: 'row', - alignItems: 'center', - }, - headerIcon: { - marginRight: 8, - }, - cardTitle: { - fontSize: 18, - fontWeight: '700', - }, - divider: { - height: 1, - width: '100%', - marginBottom: 16, - }, - inputGroup: { - marginBottom: 16, - }, - label: { - fontSize: 14, - fontWeight: '500', - marginBottom: 8, - }, - textInput: { - borderWidth: 1, - borderRadius: 12, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 16, - }, - buttonRow: { - flexDirection: 'row', - gap: 12, - marginTop: 8, - }, - saveButton: { - flex: 1, - paddingVertical: 14, - borderRadius: 12, - alignItems: 'center', - borderWidth: 1, - }, - saveButtonText: { - fontSize: 15, - fontWeight: '600', - }, - testButton: { - flex: 2, - paddingVertical: 14, - borderRadius: 12, - alignItems: 'center', - }, - testButtonDisabled: { - opacity: 0.7, - }, - testButtonText: { - fontSize: 15, - fontWeight: '600', - }, - resultBox: { - marginTop: 16, - borderRadius: 12, - padding: 14, - }, - resultText: { - fontSize: 14, - textAlign: 'center', - fontWeight: '500', - }, - row: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 4, - }, - rowIconWrap: { - marginRight: 12, - }, - rowText: { - flex: 1, - }, - rowTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 4, - }, - rowSubtitle: { - fontSize: 14, - }, - rowGroup: { - flexDirection: 'row', - alignItems: 'center', - }, - infoIcon: { - marginRight: 10, - }, - infoText: { - fontSize: 15, - lineHeight: 22, - }, }); diff --git a/components/settings/AboutCard.tsx b/components/settings/AboutCard.tsx new file mode 100644 index 0000000..7dbd3b9 --- /dev/null +++ b/components/settings/AboutCard.tsx @@ -0,0 +1,38 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; +import { Info, MonitorCog } from 'lucide-react-native'; +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { SettingsSection } from './SettingsSection'; + +export function AboutCard() { + const colors = useThemeColor(); + + return ( + + + + LocalMind - Cross Platform Version + + + + + Connected via OpenAPI `/v1/chat/completions` specs + + + + ); +} + +const styles = StyleSheet.create({ + rowGroup: { + flexDirection: 'row', + alignItems: 'center', + }, + infoIcon: { + marginRight: 10, + }, + infoText: { + fontSize: 15, + lineHeight: 22, + }, +}); diff --git a/components/settings/AppearanceCard.tsx b/components/settings/AppearanceCard.tsx new file mode 100644 index 0000000..e86cda3 --- /dev/null +++ b/components/settings/AppearanceCard.tsx @@ -0,0 +1,61 @@ +import { useColorScheme } from '@/hooks/useColorScheme'; +import { useThemeColor } from '@/hooks/useThemeColor'; +import { useAppStore } from '@/store/useAppStore'; +import { isDarkTheme } from '@/utils/theme'; +import { Moon } from 'lucide-react-native'; +import React from 'react'; +import { StyleSheet, Switch, Text, View } from 'react-native'; +import { SettingsSection } from './SettingsSection'; + +export function AppearanceCard() { + const colors = useThemeColor(); + const systemScheme = useColorScheme(); + const themePreference = useAppStore(state => state.theme); + const setTheme = useAppStore(state => state.setTheme); + + const isDarkMode = isDarkTheme(themePreference, systemScheme); + const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark'); + + return ( + + + + + + + Dark Mode + Toggle sleek dark theme + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 4, + }, + rowIconWrap: { + marginRight: 12, + }, + rowText: { + flex: 1, + }, + rowTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + rowSubtitle: { + fontSize: 14, + }, +}); diff --git a/components/settings/LabeledInput.tsx b/components/settings/LabeledInput.tsx new file mode 100644 index 0000000..3b775e9 --- /dev/null +++ b/components/settings/LabeledInput.tsx @@ -0,0 +1,44 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; +import React from 'react'; +import { StyleSheet, Text, TextInput, TextInputProps, View } from 'react-native'; + +interface LabeledInputProps extends TextInputProps { + label: string; +} + +export function LabeledInput({ label, style, ...inputProps }: LabeledInputProps) { + const colors = useThemeColor(); + + return ( + + {label} + + + ); +} + +const styles = StyleSheet.create({ + inputGroup: { + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: '500', + marginBottom: 8, + }, + textInput: { + borderWidth: 1, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 12, + fontSize: 16, + }, +}); diff --git a/components/settings/ServerConnectionCard.tsx b/components/settings/ServerConnectionCard.tsx new file mode 100644 index 0000000..a836eaa --- /dev/null +++ b/components/settings/ServerConnectionCard.tsx @@ -0,0 +1,194 @@ +import { ConnectionBadge } from '@/components/ui/ConnectionBadge'; +import { useLMStudio } from '@/hooks/useLMStudio'; +import { useThemeColor } from '@/hooks/useThemeColor'; +import * as api from '@/services/lmStudioApi'; +import { useAppStore } from '@/store/useAppStore'; +import { buildServerConfig, isConfigChanged } from '@/utils/serverConfig'; +import { Network } from 'lucide-react-native'; +import React, { useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { LabeledInput } from './LabeledInput'; +import { SettingsSection } from './SettingsSection'; + +function TestResultBox({ ok }: { ok: boolean }) { + const colors = useThemeColor(); + + return ( + + + {ok + ? 'Connection successful. API is ready.' + : 'Connection failed. Verify host and port.'} + + + ); +} + +export function ServerConnectionCard() { + const colors = useThemeColor(); + const serverConfig = useAppStore(state => state.serverConfig); + const setServerConfig = useAppStore(state => state.setServerConfig); + const isConnected = useAppStore(state => state.isConnected); + const setIsConnected = useAppStore(state => state.setIsConnected); + const { fetchModels } = useLMStudio(); + + const [host, setHost] = useState(serverConfig.host); + const [port, setPort] = useState(serverConfig.port.toString()); + const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? ''); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + const form = { host, port, apiKey }; + const currentConfig = buildServerConfig(form); + const configChanged = isConfigChanged(form, serverConfig); + + const handleSaveConfig = () => { + setServerConfig(currentConfig); + setTestResult(null); + }; + + const handleTestConnection = async () => { + handleSaveConfig(); + setIsTesting(true); + setTestResult(null); + + const ok = await api.testConnection(currentConfig); + setTestResult(ok); + setIsConnected(ok); + if (ok) { + await fetchModels(); + } + setIsTesting(false); + }; + + return ( + + + + + LM Studio API + + + + + + + + + + + + {configChanged && ( + + Save Changes + + )} + + {isTesting ? ( + + ) : ( + Test Connection + )} + + + + {testResult !== null && } + + ); +} + +const styles = StyleSheet.create({ + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 16, + }, + cardHeaderTitle: { + flexDirection: 'row', + alignItems: 'center', + }, + headerIcon: { + marginRight: 8, + }, + cardTitle: { + fontSize: 18, + fontWeight: '700', + }, + divider: { + height: 1, + width: '100%', + marginBottom: 16, + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + marginTop: 8, + }, + saveButton: { + flex: 1, + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + borderWidth: 1, + }, + saveButtonText: { + fontSize: 15, + fontWeight: '600', + }, + testButton: { + flex: 2, + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + testButtonDisabled: { + opacity: 0.7, + }, + testButtonText: { + fontSize: 15, + fontWeight: '600', + }, + resultBox: { + marginTop: 16, + borderRadius: 12, + padding: 14, + }, + resultText: { + fontSize: 14, + textAlign: 'center', + fontWeight: '500', + }, +}); diff --git a/components/settings/SettingsSection.tsx b/components/settings/SettingsSection.tsx new file mode 100644 index 0000000..cb791b7 --- /dev/null +++ b/components/settings/SettingsSection.tsx @@ -0,0 +1,45 @@ +import { useThemeColor } from '@/hooks/useThemeColor'; +import React from 'react'; +import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; + +interface SettingsSectionProps { + title: string; + children: React.ReactNode; +} + +export function SettingsSection({ title, children }: SettingsSectionProps) { + const { width } = useWindowDimensions(); + const colors = useThemeColor(); + const isLargeScreen = width > 768; + + return ( + + {title} + + {children} + + + ); +} + +const styles = StyleSheet.create({ + section: { + marginBottom: 24, + }, + desktopSection: { + width: '100%', + maxWidth: 600, + }, + sectionLabel: { + fontSize: 13, + fontWeight: '600', + marginLeft: 16, + marginBottom: 8, + letterSpacing: 0.5, + }, + card: { + borderRadius: 16, + padding: 16, + borderWidth: 1, + }, +}); diff --git a/utils/serverConfig.ts b/utils/serverConfig.ts new file mode 100644 index 0000000..d735f07 --- /dev/null +++ b/utils/serverConfig.ts @@ -0,0 +1,25 @@ +import { DEFAULT_SERVER_CONFIG, ServerConfig } from '../services/types'; + +export interface ServerConfigForm { + host: string; + port: string; + apiKey: string; +} + +/** Turns the raw text fields of the settings form into a usable ServerConfig. */ +export function buildServerConfig(form: ServerConfigForm): ServerConfig { + return { + host: form.host.trim() || DEFAULT_SERVER_CONFIG.host, + port: parseInt(form.port, 10) || DEFAULT_SERVER_CONFIG.port, + apiKey: form.apiKey.trim() || undefined, + }; +} + +/** True when the form differs from what is currently stored. */ +export function isConfigChanged(form: ServerConfigForm, saved: ServerConfig): boolean { + return ( + form.host !== saved.host || + form.port !== saved.port.toString() || + form.apiKey !== (saved.apiKey ?? '') + ); +} diff --git a/utils/theme.ts b/utils/theme.ts new file mode 100644 index 0000000..d8e08fd --- /dev/null +++ b/utils/theme.ts @@ -0,0 +1,7 @@ +/** Whether the dark theme is in effect for a stored preference and the OS scheme. */ +export function isDarkTheme( + preference: 'light' | 'dark' | 'system', + systemScheme: 'light' | 'dark', +): boolean { + return preference === 'dark' || (preference === 'system' && systemScheme === 'dark'); +}