diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx
index 4719af8..fe8ef4e 100644
--- a/src/app/(tabs)/settings.tsx
+++ b/src/app/(tabs)/settings.tsx
@@ -1,352 +1,53 @@
import { useEffect } from 'react';
import {
- Alert,
InteractionManager,
- View,
- Pressable,
ScrollView,
- StyleSheet,
- Switch,
} from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
+import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
-import { EQSlider } from '@/components/eq/EQSlider';
-import { ScanProgress } from '@/components/library/ScanProgress';
-import { SegmentedControl } from '@/components/SegmentedControl';
-import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
-import { radius, spacing } from '@/theme';
+import {
+ formatFolderCount,
+ formatTrackCount,
+ themeOptionTitle,
+} from '@/components/settings/SettingsPanels';
+import {
+ SettingsNavRow,
+ SettingsSectionLabel,
+} from '@/components/settings/SettingsSectionScaffold';
+import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
-import type { BaseThemeId, PreferredDark } from '@/theme/resolve';
-import { useSettingsStore } from '@/stores/settingsStore';
-import { useThemeStore } from '@/stores/themeStore';
-import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore';
+import { formatRelativeTime } from '@/lib/format';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
-import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
-import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
-import { formatRelativeTime } from '@/lib/format';
-import type { ReplayGainMode } from '@/audio/normalization';
-import type { ArtistGroupingMode } from '@/library/artistGrouping';
-import type { LastFmStatus } from '@/types/lastFm';
+import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
+import { useLibraryStore } from '@/stores/libraryStore';
+import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
+import { useThemeStore } from '@/stores/themeStore';
-function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
- const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
- if (connected === 0) return 'Scrobble plays to Last.fm, ListenBrainz, and more.';
- const base = `${connected} destination${connected === 1 ? '' : 's'} connected`;
- return status?.enabled ? `${base}.` : `${base} · paused.`;
-}
-
-const THEME_OPTIONS: { id: BaseThemeId; title: string; description: string }[] = [
- { id: 'system', title: 'System', description: 'Follow the Android dark/light setting.' },
- { id: 'midnight', title: 'Midnight', description: 'Deep navy. The classic Astra look.' },
- { id: 'dark', title: 'Dark', description: 'Neutral dark gray, no navy cast.' },
- { id: 'amoled', title: 'AMOLED', description: 'True black. Easy on OLED screens and batteries.' },
- { id: 'light', title: 'Light', description: 'Cool near-white with navy ink.' },
- { id: 'materialYou', title: 'Material You', description: 'Colors from your wallpaper.' },
-];
-
-const DARK_STYLE_SEGMENTS = [
- { key: 'midnight', label: 'Midnight' },
- { key: 'dark', label: 'Dark' },
- { key: 'amoled', label: 'AMOLED' },
-];
-
-/** Theme picker + accent swatches. Lives first in settings — it demos live switching. */
-function AppearanceSettings() {
- const styles = useStyles();
- const colors = useColors();
- const baseTheme = useThemeStore((s) => s.baseTheme);
- const preferredDark = useThemeStore((s) => s.preferredDark);
- const accentId = useThemeStore((s) => s.accentId);
- const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
- const resolvedId = useThemeStore((s) => s.theme.id);
- const setBaseTheme = useThemeStore((s) => s.setBaseTheme);
- const setPreferredDark = useThemeStore((s) => s.setPreferredDark);
- const setAccent = useThemeStore((s) => s.setAccent);
-
- const options = THEME_OPTIONS.filter(
- (option) => option.id !== 'materialYou' || materialYouAvailable
- );
- // Material You picks its own accent from the wallpaper — hide the swatches.
- const accentApplies = !resolvedId.startsWith('materialYou');
-
- return (
- <>
-
- {options.map((option) => {
- const selected = option.id === baseTheme;
- return (
- void setBaseTheme(option.id)}
- accessibilityRole="radio"
- accessibilityState={{ selected }}
- >
-
-
- {option.title}
-
-
- {option.description}
-
-
- {selected ? (
-
- ) : (
-
- )}
-
- );
- })}
-
-
- {baseTheme === 'system' ? (
-
-
- Dark style used when the system is dark.
-
- void setPreferredDark(key as PreferredDark)}
- />
-
- ) : null}
-
- {accentApplies ? (
-
- void setAccent(id)} />
-
- ) : null}
- >
- );
-}
-
-const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
- {
- mode: 'astra',
- title: 'Astra grouping',
- description: 'Parse collaborators ("feat.", "&", "x") — featured artists get their own entry.',
- },
- {
- mode: 'fileTags',
- title: 'File tags',
- description: 'Group by the album artist / artist tag exactly as written.',
- },
-];
-
-const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
- { mode: 'auto', label: 'Auto' },
- { mode: 'track', label: 'Track' },
- { mode: 'album', label: 'Album' },
-];
-
-function ToggleRow({
- title,
- description,
- value,
- onValueChange,
-}: {
- title: string;
- description: string;
- value: boolean;
- onValueChange: (v: boolean) => void;
-}) {
- const styles = useStyles();
- const colors = useColors();
- return (
-
-
- {title}
-
- {description}
-
-
-
-
- );
-}
-
-function formatFolderCount(count: number): string {
- return `${count} ${count === 1 ? 'folder' : 'folders'}`;
-}
-
-function formatTrackCount(count: number): string {
- return `${count} ${count === 1 ? 'track' : 'tracks'}`;
-}
-
-function LibraryFolderSettingsRow({
- folder,
- disabled,
- onRemove,
-}: {
- folder: FolderWithCount;
- disabled: boolean;
- onRemove: (folder: FolderWithCount) => void;
-}) {
- const styles = useStyles();
- const colors = useColors();
- return (
-
-
-
-
- {folder.display_name}
-
-
- {folder.available
- ? formatTrackCount(folder.track_count)
- : 'Access lost. Remove and add again.'}
-
-
- onRemove(folder)}
- accessibilityRole="button"
- accessibilityLabel={`Remove ${folder.display_name}`}
- style={disabled && styles.actionDisabled}
- >
-
-
-
- );
-}
-
-function LibraryFoldersSettings() {
- const styles = useStyles();
- const colors = useColors();
- const folders = useLibraryStore((s) => s.folders);
- const isScanning = useLibraryStore((s) => s.isScanning);
- const scanError = useLibraryStore((s) => s.scanError);
- const addFolder = useLibraryStore((s) => s.addFolder);
- const removeFolder = useLibraryStore((s) => s.removeFolder);
- const rescan = useLibraryStore((s) => s.rescan);
-
- const unavailableCount = folders.filter((folder) => !folder.available).length;
- const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
-
- const confirmRemove = (folder: FolderWithCount) => {
- Alert.alert(
- 'Remove folder?',
- `"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
- [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
- ]
- );
- };
-
- return (
-
-
-
- Local music folders
-
- {folders.length === 0
- ? 'Choose folders to scan into Astra.'
- : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
-
-
- void addFolder()}
- accessibilityRole="button"
- >
-
-
- Add
-
-
-
-
- {folders.length > 0 ? (
-
- void rescan()}
- accessibilityRole="button"
- >
-
-
- Rescan all
-
-
-
- ) : null}
-
-
-
- {scanError ? (
-
- Scan problem: {scanError}
-
- ) : null}
-
- {unavailableCount > 0 ? (
-
- {formatFolderCount(unavailableCount)} need access again.
-
- ) : null}
-
- {folders.length > 0 ? (
-
- {folders.map((folder) => (
-
- ))}
-
- ) : null}
-
- );
+function formatEnabled(value: boolean): string {
+ return value ? 'On' : 'Off';
}
export default function SettingsScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
+
+ const baseTheme = useThemeStore((s) => s.baseTheme);
+ const folders = useLibraryStore((s) => s.folders);
+ const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
+ const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
-
- const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
- const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
- const includeSingles = useSettingsStore((s) => s.includeSingles);
- const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
-
- const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
- const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
- const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
- const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
- const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
- const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
- const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
- const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
+ const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
+ const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
+ const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
@@ -355,21 +56,26 @@ export default function SettingsScreen() {
return () => task.cancel();
}, [initDesktopRemote]);
- const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
- const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
- const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
- const desktopSyncSubtitle = !desktopRemoteConnection
- ? 'Sync favorites and playlists with Astra Desktop.'
- : desktopSyncConflictCount > 0
- ? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve`
- : desktopSyncStatus === 'syncing'
- ? 'Syncing…'
- : desktopLastSyncAt !== null
- ? `Synced ${formatRelativeTime(desktopLastSyncAt)}`
- : `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · not synced yet`;
+ const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
+ const connectedScrobblers = lastFmStatus?.profiles.filter((p) => p.connected).length ?? 0;
+ const appVersion = Constants.expoConfig?.version;
+
+ const librarySubtitle = folders.length === 0
+ ? 'Folders, artist grouping, album singles.'
+ : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}. Artist grouping and albums.`;
+ const servicesSubtitle = remoteSources.length === 0 && connectedScrobblers === 0
+ ? 'Remote sources and scrobbling.'
+ : `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'}, ${connectedScrobblers} scrobble destination${connectedScrobblers === 1 ? '' : 's'}.`;
const desktopRemoteSubtitle = desktopRemoteConnection
- ? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
- : 'Pair with Astra Desktop to control playback from this phone.';
+ ? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
+ : 'Desktop Remote and Desktop Sync.';
+ const desktopSyncSubtitle = desktopSyncConflictCount > 0
+ ? `${desktopSyncConflictCount} sync conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve.`
+ : desktopSyncStatus === 'syncing'
+ ? 'Desktop Sync is running.'
+ : desktopLastSyncAt !== null
+ ? `Desktop Sync ${formatRelativeTime(desktopLastSyncAt)}.`
+ : desktopRemoteSubtitle;
return (
@@ -378,341 +84,58 @@ export default function SettingsScreen() {
Settings
-
- APPEARANCE
-
-
-
-
- LIBRARY FOLDERS
-
-
-
-
- AUDIO
-
-
- void setNormalizationEnabled(v)}
- />
- {normalizationEnabled ? (
-
- `${Math.round(v)} LUFS`}
- onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
- />
-
- ) : null}
-
-
-
- void setReplayGainEnabled(v)}
- />
- {replayGainEnabled ? (
-
- {REPLAYGAIN_MODES.map((m) => {
- const selected = m.mode === replayGainMode;
- return (
- void setReplayGainMode(m.mode)}
- >
-
- {m.label}
-
-
- );
- })}
-
- ) : null}
-
-
-
- LIBRARY
-
-
- Artist grouping
-
-
- How tracks are organized into artists in the library.
-
-
-
- {ARTIST_GROUPING_OPTIONS.map((option) => {
- const selected = option.mode === groupingMode;
- return (
- void setArtistGroupingMode(option.mode)}
- accessibilityRole="radio"
- accessibilityState={{ selected }}
- >
-
-
- {option.title}
-
-
- {option.description}
-
-
- {selected ? (
-
- ) : (
-
- )}
-
- );
- })}
-
-
- void setIncludeSingles(v)}
+ SETTINGS
+ router.push('/settings/appearance' as never)}
+ />
+ router.push('/settings/library' as never)}
+ />
+ router.push('/settings/audio' as never)}
+ />
+ router.push('/settings/services' as never)}
+ />
+ 0 ? colors.warning : undefined}
+ onPress={() => router.push('/settings/experimental' as never)}
/>
-
- REMOTE SOURCES
-
- router.push('/sources')}
- accessibilityRole="button"
- >
-
-
- Subsonic / Jellyfin servers
-
- {remoteSources.length === 0
- ? 'Stream and browse your self-hosted library.'
- : `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`}
-
-
-
-
-
-
- EXPERIMENTAL
-
- router.push('/desktop-remote' as never)}
- accessibilityRole="button"
- >
-
-
- Desktop Remote
-
- {desktopRemoteSubtitle}
-
-
-
-
- router.push('/desktop-sync' as never)}
- accessibilityRole="button"
- >
-
-
- Desktop Sync
- 0 ? colors.warning : colors.textSecondary}
- style={styles.optionDescription}
- >
- {desktopSyncSubtitle}
-
-
-
-
-
-
- SCROBBLING
-
- router.push('/lastfm')}
- accessibilityRole="button"
- >
-
-
- Last.fm & scrobbling
-
- {lastFmScrobbleSubtitle(lastFmStatus)}
-
-
-
-
+ ABOUT
+ router.push('/settings/info' as never)}
+ />
);
}
-const useStyles = createThemedStyles((colors) => ({
+const useStyles = createThemedStyles(() => ({
content: {
paddingBottom: spacing.xxl,
+ gap: spacing.sm,
},
heading: {
marginTop: spacing.xl,
marginBottom: spacing.xxl,
},
- sectionLabel: {
- letterSpacing: 1,
- marginBottom: spacing.sm,
- },
- sectionSpacing: {
- marginTop: spacing.xxl,
- },
- card: {
- borderRadius: radius.md,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.glassBorder,
- backgroundColor: colors.glassBg,
- padding: spacing.lg,
- },
- cardSpacing: {
- marginTop: spacing.sm,
- },
- folderSettingsHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.md,
- },
- folderSettingsTitleBlock: {
- flex: 1,
- minWidth: 0,
- gap: 2,
- },
- folderPrimaryAction: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- borderRadius: radius.pill,
- backgroundColor: colors.accent,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- },
- folderPrimaryActionText: {
- color: colors.bgPrimary,
- fontWeight: '600',
- },
- folderSettingsActions: {
- flexDirection: 'row',
- marginTop: spacing.md,
- },
- folderSecondaryAction: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- borderRadius: radius.pill,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.glassBorder,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- },
- folderSettingsNotice: {
- marginTop: spacing.sm,
- },
- folderSettingsList: {
- marginTop: spacing.md,
- borderTopColor: colors.glassBorder,
- borderTopWidth: StyleSheet.hairlineWidth,
- },
- folderSettingsRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.md,
- minHeight: 52,
- borderBottomColor: colors.glassBorder,
- borderBottomWidth: StyleSheet.hairlineWidth,
- paddingVertical: spacing.sm,
- },
- folderSettingsMeta: {
- flex: 1,
- minWidth: 0,
- gap: 2,
- },
- actionDisabled: {
- opacity: 0.4,
- },
- toggleRow: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.md,
- },
- toggleText: {
- flex: 1,
- gap: 2,
- },
- indent: {
- marginTop: spacing.sm,
- },
- modeRow: {
- flexDirection: 'row',
- gap: spacing.sm,
- marginTop: spacing.md,
- },
- modePill: {
- paddingHorizontal: spacing.lg,
- paddingVertical: spacing.sm,
- borderRadius: radius.pill,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.glassBorder,
- },
- modePillSelected: {
- borderColor: colors.accent,
- backgroundColor: colors.accentGlow,
- },
- settingTitle: {
- marginBottom: spacing.xs,
- },
- appearanceBlock: {
- marginTop: spacing.md,
- },
- settingNote: {
- marginBottom: spacing.md,
- },
- options: {
- gap: spacing.sm,
- },
- option: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.md,
- padding: spacing.lg,
- borderRadius: radius.md,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.glassBorder,
- backgroundColor: colors.glassBg,
- },
- optionSelected: {
- borderColor: colors.accent,
- backgroundColor: colors.glassHighlight,
- },
- optionText: {
- flex: 1,
- gap: 2,
- },
- optionDescription: {
- lineHeight: 16,
- },
}));
diff --git a/src/app/settings/appearance.tsx b/src/app/settings/appearance.tsx
new file mode 100644
index 0000000..b75f0da
--- /dev/null
+++ b/src/app/settings/appearance.tsx
@@ -0,0 +1,10 @@
+import { AppearanceSettingsPanel } from '@/components/settings/SettingsPanels';
+import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold';
+
+export default function AppearanceSettingsScreen() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/settings/audio.tsx b/src/app/settings/audio.tsx
new file mode 100644
index 0000000..36f99b5
--- /dev/null
+++ b/src/app/settings/audio.tsx
@@ -0,0 +1,10 @@
+import { AudioSettingsPanel } from '@/components/settings/SettingsPanels';
+import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold';
+
+export default function AudioSettingsScreen() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/settings/experimental.tsx b/src/app/settings/experimental.tsx
new file mode 100644
index 0000000..224f1e6
--- /dev/null
+++ b/src/app/settings/experimental.tsx
@@ -0,0 +1,62 @@
+import { useEffect } from 'react';
+import { InteractionManager } from 'react-native';
+import { useRouter } from 'expo-router';
+import {
+ SettingsNavRow,
+ SettingsSectionLabel,
+ SettingsSectionScreen,
+} from '@/components/settings/SettingsSectionScaffold';
+import { formatRelativeTime } from '@/lib/format';
+import { useColors } from '@/theme/themed';
+import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
+import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
+
+export default function ExperimentalSettingsScreen() {
+ const colors = useColors();
+ const router = useRouter();
+ const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
+ const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
+ const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
+ const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
+ const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
+ const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
+
+ useEffect(() => {
+ const task = InteractionManager.runAfterInteractions(() => {
+ void initDesktopRemote();
+ });
+ return () => task.cancel();
+ }, [initDesktopRemote]);
+
+ const desktopRemoteSubtitle = desktopRemoteConnection
+ ? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
+ : 'Pair with Astra Desktop to control playback from this phone.';
+ const desktopSyncSubtitle = !desktopRemoteConnection
+ ? 'Sync favorites and playlists with Astra Desktop.'
+ : desktopSyncConflictCount > 0
+ ? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve.`
+ : desktopSyncStatus === 'syncing'
+ ? 'Syncing.'
+ : desktopLastSyncAt !== null
+ ? `Synced ${formatRelativeTime(desktopLastSyncAt)}.`
+ : `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: not synced yet.`;
+
+ return (
+
+ DESKTOP
+ router.push('/desktop-remote' as never)}
+ />
+ 0 ? colors.warning : undefined}
+ onPress={() => router.push('/desktop-sync' as never)}
+ />
+
+ );
+}
diff --git a/src/app/settings/info.tsx b/src/app/settings/info.tsx
new file mode 100644
index 0000000..ef67013
--- /dev/null
+++ b/src/app/settings/info.tsx
@@ -0,0 +1,112 @@
+import {
+ Alert,
+ Linking,
+ View,
+} from 'react-native';
+import Constants from 'expo-constants';
+import {
+ SettingsCard,
+ SettingsNavRow,
+ SettingsSectionLabel,
+ SettingsSectionScreen,
+} from '@/components/settings/SettingsSectionScaffold';
+import { Text } from '@/components/Text';
+import { spacing } from '@/theme';
+import { createThemedStyles, useColors } from '@/theme/themed';
+
+const ASTRA_REPOSITORY_URL = 'https://github.com/Boof2015/astra-mobile';
+const ASTRA_DISCORD_URL = 'https://discord.gg/hsKK8Kr9Nj';
+const ASTRA_SUPPORT_URL = 'https://ko-fi.com/boof2015';
+const ASTRA_LICENSE_URL = 'https://github.com/Boof2015/astra-mobile/blob/main/LICENSE';
+const GPL_V3_URL = 'https://www.gnu.org/licenses/gpl-3.0.html';
+
+async function openExternalLink(url: string, label: string) {
+ try {
+ await Linking.openURL(url);
+ } catch {
+ Alert.alert('Unable to open link', `Astra could not open ${label}.`);
+ }
+}
+
+export default function InfoSettingsScreen() {
+ const styles = useStyles();
+ const colors = useColors();
+ const appVersion = Constants.expoConfig?.version;
+ const appVersionLabel = appVersion ? `v${appVersion}` : 'Unavailable';
+
+ return (
+
+ APP
+
+
+
+ App Version
+
+ {appVersionLabel}
+
+
+
+ ATTRIBUTION
+
+
+ Astra is created and maintained by Boof2015.
+
+
+ contact@novaml.ai
+
+
+ void openExternalLink(ASTRA_REPOSITORY_URL, 'GitHub Repository')}
+ />
+ void openExternalLink(ASTRA_DISCORD_URL, 'Discord')}
+ />
+ void openExternalLink(ASTRA_SUPPORT_URL, 'Ko-fi')}
+ />
+
+ LICENSE
+
+
+ Astra is distributed under GPL-3.0-only.
+
+
+ void openExternalLink(ASTRA_LICENSE_URL, 'the Astra license')}
+ />
+ void openExternalLink(GPL_V3_URL, 'GPL v3 Text')}
+ />
+
+ );
+}
+
+const useStyles = createThemedStyles(() => ({
+ infoRow: {
+ gap: spacing.xs,
+ },
+ paragraph: {
+ lineHeight: 21,
+ marginBottom: spacing.sm,
+ },
+}));
diff --git a/src/app/settings/library.tsx b/src/app/settings/library.tsx
new file mode 100644
index 0000000..1c8c631
--- /dev/null
+++ b/src/app/settings/library.tsx
@@ -0,0 +1,10 @@
+import { LibrarySettingsPanel } from '@/components/settings/SettingsPanels';
+import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold';
+
+export default function LibrarySettingsScreen() {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/settings/services.tsx b/src/app/settings/services.tsx
new file mode 100644
index 0000000..d1a7e60
--- /dev/null
+++ b/src/app/settings/services.tsx
@@ -0,0 +1,39 @@
+import { useRouter } from 'expo-router';
+import { lastFmScrobbleSubtitle } from '@/components/settings/SettingsPanels';
+import {
+ SettingsNavRow,
+ SettingsSectionLabel,
+ SettingsSectionScreen,
+} from '@/components/settings/SettingsSectionScaffold';
+import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
+import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
+
+export default function ServicesSettingsScreen() {
+ const router = useRouter();
+ const remoteSources = useRemoteSourcesStore((s) => s.sources);
+ const lastFmStatus = useLastFmSettingsStore((s) => s.status);
+
+ return (
+
+ REMOTE SOURCES
+ router.push('/sources')}
+ />
+
+ SCROBBLING
+ router.push('/lastfm')}
+ />
+
+ );
+}
diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx
index 34bc1c8..9602442 100644
--- a/src/components/search/QuickSearchOverlay.tsx
+++ b/src/components/search/QuickSearchOverlay.tsx
@@ -51,7 +51,19 @@ import type {
import type { Playlist } from '@/types/playlist';
type IconName = keyof typeof Ionicons.glyphMap;
-type RouteHref = '/' | '/library' | '/eq' | '/settings' | '/sources' | '/lastfm';
+type RouteHref =
+ | '/'
+ | '/library'
+ | '/eq'
+ | '/settings'
+ | '/settings/appearance'
+ | '/settings/library'
+ | '/settings/audio'
+ | '/settings/services'
+ | '/settings/experimental'
+ | '/settings/info'
+ | '/sources'
+ | '/lastfm';
type LibraryViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
const NAV_RESULT_LIMIT = 3;
@@ -119,35 +131,51 @@ const SETTING_ENTRIES: {
keywords: string[];
libraryViewMode?: LibraryViewMode;
}[] = [
+ {
+ id: 'setting:appearance',
+ label: 'Appearance settings',
+ subtitle: 'Theme / dark style / accent',
+ href: '/settings/appearance',
+ icon: 'color-palette-outline',
+ keywords: ['appearance', 'theme', 'dark mode', 'amoled', 'material you', 'accent', 'color'],
+ },
{
id: 'setting:audio',
label: 'Audio settings',
subtitle: 'Normalization / ReplayGain',
- href: '/settings',
+ href: '/settings/audio',
icon: 'volume-high',
- keywords: ['normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
+ keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
},
{
id: 'setting:library',
- label: 'Artist grouping',
- subtitle: 'Astra grouping / file tags',
- href: '/settings',
+ label: 'Library settings',
+ subtitle: 'Folders / artist grouping / albums',
+ href: '/settings/library',
icon: 'people',
- keywords: ['library', 'artist grouping', 'collaborators', 'file tags'],
+ keywords: ['library', 'artist grouping', 'collaborators', 'file tags', 'singles', 'albums'],
},
{
id: 'setting:folders',
label: 'Manage folders',
subtitle: 'Add / rescan / remove folders',
- href: '/settings',
+ href: '/settings/library',
icon: 'folder-open-outline',
keywords: ['folders', 'scan', 'rescan', 'add folder', 'remove folder', 'local files', 'storage'],
},
+ {
+ id: 'setting:services',
+ label: 'Services settings',
+ subtitle: 'Remote sources / scrobbling',
+ href: '/settings/services',
+ icon: 'server-outline',
+ keywords: ['services', 'integrations', 'remote sources', 'scrobbling'],
+ },
{
id: 'setting:sources',
label: 'Remote sources',
subtitle: 'Subsonic / Jellyfin servers',
- href: '/sources',
+ href: '/settings/services',
icon: 'server-outline',
keywords: ['subsonic', 'jellyfin', 'server', 'streaming', 'remote'],
},
@@ -155,10 +183,26 @@ const SETTING_ENTRIES: {
id: 'setting:lastfm',
label: 'Scrobbling',
subtitle: 'Last.fm / ListenBrainz',
- href: '/lastfm',
+ href: '/settings/services',
icon: 'radio-outline',
keywords: ['lastfm', 'last.fm', 'listenbrainz', 'scrobble', 'audioscrobbler'],
},
+ {
+ id: 'setting:experimental',
+ label: 'Experimental settings',
+ subtitle: 'Desktop Remote / Desktop Sync',
+ href: '/settings/experimental',
+ icon: 'flask-outline',
+ keywords: ['experimental', 'desktop remote', 'desktop sync', 'pairing', 'phone remote'],
+ },
+ {
+ id: 'setting:info',
+ label: 'Info',
+ subtitle: 'Version / attribution / license',
+ href: '/settings/info',
+ icon: 'information-circle-outline',
+ keywords: ['info', 'about', 'version', 'license', 'attribution', 'github', 'repo', 'repository', 'discord', 'kofi', 'ko-fi', 'support', 'gpl'],
+ },
];
const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm'];
@@ -829,7 +873,7 @@ function QuickSearchPanel({
};
const navigateTo = (href: RouteHref) => {
- router.push(href);
+ router.push(href as never);
};
const executeResult = (result: SearchResult) => {
diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx
new file mode 100644
index 0000000..44fcf83
--- /dev/null
+++ b/src/components/settings/SettingsPanels.tsx
@@ -0,0 +1,541 @@
+import {
+ Alert,
+ Pressable,
+ StyleSheet,
+ View,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { EQSlider } from '@/components/eq/EQSlider';
+import { ScanProgress } from '@/components/library/ScanProgress';
+import { SegmentedControl } from '@/components/SegmentedControl';
+import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
+import {
+ SettingsCard,
+ SettingsSectionLabel,
+ SettingsToggleRow,
+} from '@/components/settings/SettingsSectionScaffold';
+import { radius, spacing } from '@/theme';
+import { createThemedStyles, useColors } from '@/theme/themed';
+import type { ReplayGainMode } from '@/audio/normalization';
+import type { ArtistGroupingMode } from '@/library/artistGrouping';
+import type { BaseThemeId, PreferredDark } from '@/theme/resolve';
+import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
+import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore';
+import { useSettingsStore } from '@/stores/settingsStore';
+import { useThemeStore } from '@/stores/themeStore';
+import type { LastFmStatus } from '@/types/lastFm';
+import { Text } from '@/components/Text';
+
+export function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
+ const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
+ if (connected === 0) return 'Scrobble plays to Last.fm, ListenBrainz, and more.';
+ const base = `${connected} destination${connected === 1 ? '' : 's'} connected`;
+ return status?.enabled ? `${base}.` : `${base}. Paused.`;
+}
+
+export function formatFolderCount(count: number): string {
+ return `${count} ${count === 1 ? 'folder' : 'folders'}`;
+}
+
+export function formatTrackCount(count: number): string {
+ return `${count} ${count === 1 ? 'track' : 'tracks'}`;
+}
+
+const THEME_OPTIONS: { id: BaseThemeId; title: string; description: string }[] = [
+ { id: 'system', title: 'System', description: 'Follow the Android dark/light setting.' },
+ { id: 'midnight', title: 'Midnight', description: 'Deep navy. The classic Astra look.' },
+ { id: 'dark', title: 'Dark', description: 'Neutral dark gray, no navy cast.' },
+ { id: 'amoled', title: 'AMOLED', description: 'True black. Easy on OLED screens and batteries.' },
+ { id: 'light', title: 'Light', description: 'Cool near-white with navy ink.' },
+ { id: 'materialYou', title: 'Material You', description: 'Colors from your wallpaper.' },
+];
+
+const DARK_STYLE_SEGMENTS = [
+ { key: 'midnight', label: 'Midnight' },
+ { key: 'dark', label: 'Dark' },
+ { key: 'amoled', label: 'AMOLED' },
+];
+
+const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
+ {
+ mode: 'astra',
+ title: 'Astra grouping',
+ description: 'Parse collaborators ("feat.", "&", "x"). Featured artists get their own entry.',
+ },
+ {
+ mode: 'fileTags',
+ title: 'File tags',
+ description: 'Group by the album artist / artist tag exactly as written.',
+ },
+];
+
+const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
+ { mode: 'auto', label: 'Auto' },
+ { mode: 'track', label: 'Track' },
+ { mode: 'album', label: 'Album' },
+];
+
+export function themeOptionTitle(id: BaseThemeId): string {
+ return THEME_OPTIONS.find((option) => option.id === id)?.title ?? 'System';
+}
+
+export function AppearanceSettingsPanel() {
+ const styles = useStyles();
+ const colors = useColors();
+ const baseTheme = useThemeStore((s) => s.baseTheme);
+ const preferredDark = useThemeStore((s) => s.preferredDark);
+ const accentId = useThemeStore((s) => s.accentId);
+ const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
+ const resolvedId = useThemeStore((s) => s.theme.id);
+ const setBaseTheme = useThemeStore((s) => s.setBaseTheme);
+ const setPreferredDark = useThemeStore((s) => s.setPreferredDark);
+ const setAccent = useThemeStore((s) => s.setAccent);
+
+ const options = THEME_OPTIONS.filter(
+ (option) => option.id !== 'materialYou' || materialYouAvailable
+ );
+ const accentApplies = !resolvedId.startsWith('materialYou');
+
+ return (
+ <>
+
+ {options.map((option) => {
+ const selected = option.id === baseTheme;
+ return (
+ void setBaseTheme(option.id)}
+ accessibilityRole="radio"
+ accessibilityState={{ selected }}
+ >
+
+
+ {option.title}
+
+
+ {option.description}
+
+
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ {baseTheme === 'system' ? (
+
+
+ Dark style used when the system is dark.
+
+ void setPreferredDark(key as PreferredDark)}
+ />
+
+ ) : null}
+
+ {accentApplies ? (
+
+ void setAccent(id)} />
+
+ ) : null}
+ >
+ );
+}
+
+function LibraryFolderSettingsRow({
+ folder,
+ disabled,
+ onRemove,
+}: {
+ folder: FolderWithCount;
+ disabled: boolean;
+ onRemove: (folder: FolderWithCount) => void;
+}) {
+ const styles = useStyles();
+ const colors = useColors();
+ return (
+
+
+
+
+ {folder.display_name}
+
+
+ {folder.available
+ ? formatTrackCount(folder.track_count)
+ : 'Access lost. Remove and add again.'}
+
+
+ onRemove(folder)}
+ accessibilityRole="button"
+ accessibilityLabel={`Remove ${folder.display_name}`}
+ style={disabled && styles.actionDisabled}
+ >
+
+
+
+ );
+}
+
+function LibraryFoldersSettings() {
+ const styles = useStyles();
+ const colors = useColors();
+ const folders = useLibraryStore((s) => s.folders);
+ const isScanning = useLibraryStore((s) => s.isScanning);
+ const scanError = useLibraryStore((s) => s.scanError);
+ const addFolder = useLibraryStore((s) => s.addFolder);
+ const removeFolder = useLibraryStore((s) => s.removeFolder);
+ const rescan = useLibraryStore((s) => s.rescan);
+
+ const unavailableCount = folders.filter((folder) => !folder.available).length;
+ const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
+
+ const confirmRemove = (folder: FolderWithCount) => {
+ Alert.alert(
+ 'Remove folder?',
+ `"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
+ ]
+ );
+ };
+
+ return (
+
+
+
+ Local music folders
+
+ {folders.length === 0
+ ? 'Choose folders to scan into Astra.'
+ : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
+
+
+ void addFolder()}
+ accessibilityRole="button"
+ >
+
+
+ Add
+
+
+
+
+ {folders.length > 0 ? (
+
+ void rescan()}
+ accessibilityRole="button"
+ >
+
+
+ Rescan all
+
+
+
+ ) : null}
+
+
+
+ {scanError ? (
+
+ Scan problem: {scanError}
+
+ ) : null}
+
+ {unavailableCount > 0 ? (
+
+ {formatFolderCount(unavailableCount)} need access again.
+
+ ) : null}
+
+ {folders.length > 0 ? (
+
+ {folders.map((folder) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function LibrarySettingsPanel() {
+ const styles = useStyles();
+ const colors = useColors();
+ const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
+ const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
+ const includeSingles = useSettingsStore((s) => s.includeSingles);
+ const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
+
+ return (
+ <>
+ LOCAL FOLDERS
+
+
+ LIBRARY VIEW
+
+ Artist grouping
+
+
+ How tracks are organized into artists in the library.
+
+
+
+ {ARTIST_GROUPING_OPTIONS.map((option) => {
+ const selected = option.mode === groupingMode;
+ return (
+ void setArtistGroupingMode(option.mode)}
+ accessibilityRole="radio"
+ accessibilityState={{ selected }}
+ >
+
+
+ {option.title}
+
+
+ {option.description}
+
+
+ {selected ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+ void setIncludeSingles(v)}
+ />
+
+ >
+ );
+}
+
+export function AudioSettingsPanel() {
+ const styles = useStyles();
+ const colors = useColors();
+ const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
+ const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
+ const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
+ const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
+ const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
+ const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
+ const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
+ const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
+
+ return (
+ <>
+ LOUDNESS
+
+ void setNormalizationEnabled(v)}
+ />
+ {normalizationEnabled ? (
+
+ `${Math.round(v)} LUFS`}
+ onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
+ />
+
+ ) : null}
+
+
+ REPLAYGAIN
+
+ void setReplayGainEnabled(v)}
+ />
+ {replayGainEnabled ? (
+
+ {REPLAYGAIN_MODES.map((m) => {
+ const selected = m.mode === replayGainMode;
+ return (
+ void setReplayGainMode(m.mode)}
+ >
+
+ {m.label}
+
+
+ );
+ })}
+
+ ) : null}
+
+ >
+ );
+}
+
+const useStyles = createThemedStyles((colors) => ({
+ appearanceBlock: {
+ marginTop: spacing.md,
+ },
+ settingTitle: {
+ marginBottom: spacing.xs,
+ },
+ settingNote: {
+ marginBottom: spacing.md,
+ },
+ options: {
+ gap: spacing.sm,
+ },
+ option: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ padding: spacing.lg,
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.glassBg,
+ },
+ optionSelected: {
+ borderColor: colors.accent,
+ backgroundColor: colors.glassHighlight,
+ },
+ optionText: {
+ flex: 1,
+ minWidth: 0,
+ gap: 2,
+ },
+ optionDescription: {
+ lineHeight: 16,
+ },
+ folderSettingsHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ },
+ folderSettingsTitleBlock: {
+ flex: 1,
+ minWidth: 0,
+ gap: 2,
+ },
+ folderPrimaryAction: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ borderRadius: radius.pill,
+ backgroundColor: colors.accent,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ },
+ folderPrimaryActionText: {
+ color: colors.bgPrimary,
+ fontWeight: '600',
+ },
+ folderSettingsActions: {
+ flexDirection: 'row',
+ marginTop: spacing.md,
+ },
+ folderSecondaryAction: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ borderRadius: radius.pill,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ },
+ folderSettingsNotice: {
+ marginTop: spacing.sm,
+ },
+ folderSettingsList: {
+ marginTop: spacing.md,
+ borderTopColor: colors.glassBorder,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ },
+ folderSettingsRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ minHeight: 52,
+ borderBottomColor: colors.glassBorder,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ paddingVertical: spacing.sm,
+ },
+ folderSettingsMeta: {
+ flex: 1,
+ minWidth: 0,
+ gap: 2,
+ },
+ actionDisabled: {
+ opacity: 0.4,
+ },
+ cardSpacing: {
+ marginTop: spacing.sm,
+ },
+ indent: {
+ marginTop: spacing.sm,
+ },
+ modeRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.sm,
+ marginTop: spacing.md,
+ },
+ modePill: {
+ paddingHorizontal: spacing.lg,
+ paddingVertical: spacing.sm,
+ borderRadius: radius.pill,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ },
+ modePillSelected: {
+ borderColor: colors.accent,
+ backgroundColor: colors.accentGlow,
+ },
+}));
diff --git a/src/components/settings/SettingsSectionScaffold.tsx b/src/components/settings/SettingsSectionScaffold.tsx
new file mode 100644
index 0000000..04543ec
--- /dev/null
+++ b/src/components/settings/SettingsSectionScaffold.tsx
@@ -0,0 +1,223 @@
+import type { ReactNode } from 'react';
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ View,
+ type StyleProp,
+ type ViewStyle,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useRouter } from 'expo-router';
+import { Screen } from '@/components/Screen';
+import { Text } from '@/components/Text';
+import { radius, spacing } from '@/theme';
+import { createThemedStyles, useColors } from '@/theme/themed';
+
+export type SettingsIconName = keyof typeof Ionicons.glyphMap;
+
+export function SettingsSectionScreen({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ const styles = useStyles();
+ const colors = useColors();
+ const router = useRouter();
+
+ return (
+
+
+ router.back()} hitSlop={8}>
+
+
+ Settings
+
+
+
+
+
+ {title}
+
+
+
+ {children}
+
+
+ );
+}
+
+export function SettingsCard({
+ children,
+ style,
+}: {
+ children: ReactNode;
+ style?: StyleProp;
+}) {
+ const styles = useStyles();
+ return {children};
+}
+
+export function SettingsSectionLabel({
+ children,
+ spaced = false,
+}: {
+ children: ReactNode;
+ spaced?: boolean;
+}) {
+ const styles = useStyles();
+ const colors = useColors();
+ return (
+
+ {children}
+
+ );
+}
+
+export function SettingsNavRow({
+ icon,
+ title,
+ subtitle,
+ onPress,
+ subtitleColor,
+ rightIcon = 'chevron-forward',
+}: {
+ icon: SettingsIconName;
+ title: string;
+ subtitle: string;
+ onPress: () => void;
+ subtitleColor?: string;
+ rightIcon?: SettingsIconName;
+}) {
+ const styles = useStyles();
+ const colors = useColors();
+ return (
+
+
+
+
+
+ {title}
+
+ {subtitle}
+
+
+
+
+ );
+}
+
+export function SettingsToggleRow({
+ title,
+ description,
+ value,
+ onValueChange,
+}: {
+ title: string;
+ description: string;
+ value: boolean;
+ onValueChange: (v: boolean) => void;
+}) {
+ const styles = useStyles();
+ const colors = useColors();
+ return (
+
+
+ {title}
+
+ {description}
+
+
+
+
+ );
+}
+
+const useStyles = createThemedStyles((colors) => ({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: spacing.md,
+ },
+ back: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 2,
+ },
+ heading: {
+ marginTop: spacing.lg,
+ marginBottom: spacing.lg,
+ },
+ content: {
+ paddingBottom: spacing.xxl,
+ gap: spacing.sm,
+ },
+ sectionLabel: {
+ letterSpacing: 1,
+ marginBottom: spacing.sm,
+ },
+ sectionSpacing: {
+ marginTop: spacing.xxl,
+ },
+ card: {
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.glassBg,
+ padding: spacing.lg,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ padding: spacing.lg,
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.glassBg,
+ },
+ rowIcon: {
+ width: 36,
+ height: 36,
+ borderRadius: radius.sm,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.bgTertiary,
+ },
+ rowMeta: {
+ flex: 1,
+ minWidth: 0,
+ gap: 2,
+ },
+ rowSubtitle: {
+ lineHeight: 16,
+ },
+ toggleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ },
+ toggleText: {
+ flex: 1,
+ minWidth: 0,
+ gap: 2,
+ },
+}));