settings revamp

This commit is contained in:
Boof2015
2026-07-06 21:03:58 -04:00
parent efd2ebc91a
commit 31f81282e0
10 changed files with 1147 additions and 673 deletions
+55 -11
View File
@@ -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) => {
+541
View File
@@ -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 (
<>
<View style={styles.options}>
{options.map((option) => {
const selected = option.id === baseTheme;
return (
<Pressable
key={option.id}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setBaseTheme(option.id)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
{baseTheme === 'system' ? (
<View style={styles.appearanceBlock}>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
Dark style used when the system is dark.
</Text>
<SegmentedControl
segments={DARK_STYLE_SEGMENTS}
value={preferredDark}
onChange={(key) => void setPreferredDark(key as PreferredDark)}
/>
</View>
) : null}
{accentApplies ? (
<View style={styles.appearanceBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
</View>
) : null}
</>
);
}
function LibraryFolderSettingsRow({
folder,
disabled,
onRemove,
}: {
folder: FolderWithCount;
disabled: boolean;
onRemove: (folder: FolderWithCount) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.folderSettingsRow}>
<Ionicons
name={folder.available ? 'folder-outline' : 'alert-circle-outline'}
size={20}
color={folder.available ? colors.textSecondary : colors.warning}
/>
<View style={styles.folderSettingsMeta}>
<Text variant="body" numberOfLines={1}>
{folder.display_name}
</Text>
<Text
variant="caption"
color={folder.available ? colors.textSecondary : colors.warning}
numberOfLines={1}
>
{folder.available
? formatTrackCount(folder.track_count)
: 'Access lost. Remove and add again.'}
</Text>
</View>
<Pressable
hitSlop={8}
disabled={disabled}
onPress={() => onRemove(folder)}
accessibilityRole="button"
accessibilityLabel={`Remove ${folder.display_name}`}
style={disabled && styles.actionDisabled}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
</View>
);
}
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 (
<SettingsCard>
<View style={styles.folderSettingsHeader}>
<View style={styles.folderSettingsTitleBlock}>
<Text variant="body">Local music folders</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{folders.length === 0
? 'Choose folders to scan into Astra.'
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
</Text>
</View>
<Pressable
style={[styles.folderPrimaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="add" size={17} color={colors.bgPrimary} />
<Text variant="label" style={styles.folderPrimaryActionText}>
Add
</Text>
</Pressable>
</View>
{folders.length > 0 ? (
<View style={styles.folderSettingsActions}>
<Pressable
style={[styles.folderSecondaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void rescan()}
accessibilityRole="button"
>
<Ionicons name="refresh" size={16} color={colors.textSecondary} />
<Text variant="label" color={colors.textSecondary}>
Rescan all
</Text>
</Pressable>
</View>
) : null}
<ScanProgress />
{scanError ? (
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
Scan problem: {scanError}
</Text>
) : null}
{unavailableCount > 0 ? (
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
{formatFolderCount(unavailableCount)} need access again.
</Text>
) : null}
{folders.length > 0 ? (
<View style={styles.folderSettingsList}>
{folders.map((folder) => (
<LibraryFolderSettingsRow
key={folder.id}
folder={folder}
disabled={isScanning}
onRemove={confirmRemove}
/>
))}
</View>
) : null}
</SettingsCard>
);
}
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 (
<>
<SettingsSectionLabel>LOCAL FOLDERS</SettingsSectionLabel>
<LibraryFoldersSettings />
<SettingsSectionLabel spaced>LIBRARY VIEW</SettingsSectionLabel>
<Text variant="body" style={styles.settingTitle}>
Artist grouping
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
How tracks are organized into artists in the library.
</Text>
<View style={styles.options}>
{ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode;
return (
<Pressable
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
<SettingsCard style={styles.cardSpacing}>
<SettingsToggleRow
title="Show singles in Albums"
description="Include 1-track albums in the Albums view. Off matches desktop."
value={includeSingles}
onValueChange={(v) => void setIncludeSingles(v)}
/>
</SettingsCard>
</>
);
}
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 (
<>
<SettingsSectionLabel>LOUDNESS</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="Loudness normalization"
description="Level every track to a target loudness. Keeps playback volume and scopes consistent."
value={normalizationEnabled}
onValueChange={(v) => void setNormalizationEnabled(v)}
/>
{normalizationEnabled ? (
<View style={styles.indent}>
<EQSlider
label="Target"
value={normalizationTargetLufs}
min={-30}
max={-5}
format={(v) => `${Math.round(v)} LUFS`}
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
/>
</View>
) : null}
</SettingsCard>
<SettingsSectionLabel spaced>REPLAYGAIN</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="ReplayGain"
description="Use ReplayGain tags when present; falls back to the measured loudness above."
value={replayGainEnabled}
onValueChange={(v) => void setReplayGainEnabled(v)}
/>
{replayGainEnabled ? (
<View style={styles.modeRow}>
{REPLAYGAIN_MODES.map((m) => {
const selected = m.mode === replayGainMode;
return (
<Pressable
key={m.mode}
style={[styles.modePill, selected && styles.modePillSelected]}
onPress={() => void setReplayGainMode(m.mode)}
>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
{m.label}
</Text>
</Pressable>
);
})}
</View>
) : null}
</SettingsCard>
</>
);
}
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,
},
}));
@@ -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 (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Settings
</Text>
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
{title}
</Text>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
{children}
</ScrollView>
</Screen>
);
}
export function SettingsCard({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const styles = useStyles();
return <View style={[styles.card, style]}>{children}</View>;
}
export function SettingsSectionLabel({
children,
spaced = false,
}: {
children: ReactNode;
spaced?: boolean;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Text
variant="label"
color={colors.textTertiary}
style={[styles.sectionLabel, spaced && styles.sectionSpacing]}
>
{children}
</Text>
);
}
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 (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.rowIcon}>
<Ionicons name={icon} size={20} color={colors.accent} />
</View>
<View style={styles.rowMeta}>
<Text variant="body">{title}</Text>
<Text
variant="caption"
color={subtitleColor ?? colors.textSecondary}
numberOfLines={2}
style={styles.rowSubtitle}
>
{subtitle}
</Text>
</View>
<Ionicons name={rightIcon} size={18} color={colors.textTertiary} />
</Pressable>
);
}
export function SettingsToggleRow({
title,
description,
value,
onValueChange,
}: {
title: string;
description: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">{title}</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.rowSubtitle}>
{description}
</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
);
}
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,
},
}));