mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
settings revamp
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user