theming + material you

This commit is contained in:
Boof2015
2026-07-06 20:38:42 -04:00
parent e1f99f6d61
commit efd2ebc91a
87 changed files with 1528 additions and 327 deletions
+2 -1
View File
@@ -3,11 +3,12 @@ import { Tabs } from 'expo-router';
// Animated.timing and may use the native driver, so the easing must be serializable.
import { Easing } from 'react-native';
import { TabBar, type TabItem } from '@/components/TabBar';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
const TAB_TRANSITION_MS = 160;
export default function TabsLayout() {
const colors = useColors();
return (
<Tabs
detachInactiveScreens
+6 -4
View File
@@ -24,10 +24,10 @@ import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { isWideWindow } from '@/theme/adaptive';
import { useEQStore } from '@/stores/eqStore';
import { useScopeActive } from '@/scope/scopeStore';
@@ -52,6 +52,8 @@ type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
export default function EQScreen() {
const styles = useStyles();
const colors = useColors();
const eq = useEQStore();
const scopeActive = useScopeActive();
const [focused, setFocused] = useState(false);
@@ -394,7 +396,7 @@ function parsePlainNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -501,4 +503,4 @@ const styles = StyleSheet.create({
marginTop: spacing.xs,
marginBottom: spacing.sm,
},
});
}));
+18 -5
View File
@@ -23,11 +23,12 @@ import {
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import {
colors,
fonts,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { rgbaFromHex } from '@/theme/colorUtils';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -90,6 +91,8 @@ function SectionHeader({
actionLabel?: string;
onActionPress?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}>
@@ -115,6 +118,7 @@ function SectionHeader({
}
function AlbumCover({ album, size }: { album: Album; size: number }) {
const styles = useStyles();
const artUri = albumArtworkSource(album);
return (
<View style={[styles.albumArt, { width: size, height: size }]}>
@@ -145,6 +149,8 @@ function NowPlayingCard({
duration: number;
onOpen: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
@@ -231,6 +237,7 @@ function RecentlyAddedAlbum({
album: Album;
onPress: () => void;
}) {
const styles = useStyles();
return (
<Pressable style={styles.recentAlbum} onPress={onPress} accessibilityRole="button">
<AlbumCover album={album} size={112} />
@@ -259,6 +266,8 @@ function RandomAlbumCard({
onReroll: () => void;
onOpen: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const disabled = tracks.length === 0;
return (
@@ -325,6 +334,8 @@ function EmptyHomeCard({
scanError: string | null;
onManageFolders: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.emptyCard}>
<Ionicons name="folder-open-outline" size={34} color={colors.textTertiary} />
@@ -354,6 +365,8 @@ function EmptyHomeCard({
}
export default function HomeScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const tracks = useLibraryStore((s) => s.tracks);
const albums = useLibraryStore((s) => s.albums);
@@ -588,7 +601,7 @@ export default function HomeScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
content: {
paddingBottom: spacing.xxl,
},
@@ -636,7 +649,7 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(8, 10, 15, 0.28)',
backgroundColor: rgbaFromHex(colors.bgPrimary, 0.28),
},
playerArt: {
width: 112,
@@ -813,4 +826,4 @@ const styles = StyleSheet.create({
emptyCopy: {
gap: spacing.xs,
},
});
}));
+2 -1
View File
@@ -1,11 +1,12 @@
import { Stack } from 'expo-router';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
/**
* Nested stack inside the Library tab so album/artist detail screens keep the
* tab bar + mini-player visible.
*/
export default function LibraryLayout() {
const colors = useColors();
return (
<Stack
screenOptions={{
+4 -1
View File
@@ -11,7 +11,8 @@ import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
@@ -27,6 +28,7 @@ type AlbumRow =
| { kind: 'disc'; disc: number };
function DiscHeader({ disc }: { disc: number }) {
const colors = useColors();
return (
<View style={styles.discHeader}>
<Ionicons name="disc-outline" size={16} color={colors.textSecondary} />
@@ -36,6 +38,7 @@ function DiscHeader({ disc }: { disc: number }) {
}
export default function AlbumScreen() {
const colors = useColors();
const { key, from } = useLocalSearchParams<{ key: string; from?: string }>();
const albums = useLibraryStore((s) => s.albums);
const allTracks = useLibraryStore((s) => s.tracks);
+18 -6
View File
@@ -21,11 +21,12 @@ import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import {
colors,
fontSize,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { Palette } from '@/theme/palettes';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -60,6 +61,8 @@ type ArtistPageItem =
| { key: 'empty'; type: 'empty' };
export default function ArtistScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { name = 'Artist', from } = useLocalSearchParams<{ name: string; from?: string }>();
const handleBack = useLibraryDetailBack(from);
@@ -165,7 +168,7 @@ export default function ArtistScreen() {
}}
/>
<CollapsingHeader
artwork={artistArtwork(detail.artworkHashes)}
artwork={artistArtwork(detail.artworkHashes, colors, styles)}
backdropUri={backdropHash ? artworkThumbUri(backdropHash) : null}
title={name}
heroMeta={
@@ -247,7 +250,11 @@ function buildListItems(detail: ArtistDetail): ArtistPageItem[] {
}
/** Inner artwork for the collapsing header: 2x2 album mosaic, single cover, or fallback. */
function artistArtwork(hashes: string[]) {
function artistArtwork(
hashes: string[],
colors: Palette,
styles: ReturnType<typeof useStyles>,
) {
const useMosaic = hashes.length >= 4;
const display = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
@@ -283,6 +290,8 @@ function SectionHeader({
trailing: string;
onPress?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}>
@@ -312,6 +321,7 @@ function AlbumRail({
albums: ArtistAlbum[];
onAlbumPress: (album: ArtistAlbum) => void;
}) {
const styles = useStyles();
return (
<ScrollView
horizontal
@@ -352,6 +362,8 @@ function AlbumRail({
}
function StatChip({ icon, label }: { icon: IconName; label: string }) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.statChip}>
<Ionicons name={icon} size={13} color={colors.accentText} />
@@ -380,7 +392,7 @@ function trackSubtitle(track: DbTrack, section: 'appearances' | 'songs'): string
return track.album;
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
@@ -482,4 +494,4 @@ const styles = StyleSheet.create({
emptyText: {
textAlign: 'center',
},
});
}));
@@ -10,12 +10,14 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { buildArtistDetail } from '@/library/artistDetail';
export default function ArtistAlbumsScreen() {
const colors = useColors();
const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
@@ -68,6 +70,7 @@ export default function ArtistAlbumsScreen() {
}
function EmptyList({ label }: { label: string }) {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="albums-outline" size={24} color={colors.textTertiary} />
@@ -11,7 +11,8 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -21,6 +22,7 @@ import { buildArtistDetail } from '@/library/artistDetail';
import type { DbTrack } from '@/types/library';
export default function ArtistAppearancesScreen() {
const colors = useColors();
const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
@@ -79,6 +81,7 @@ export default function ArtistAppearancesScreen() {
}
function EmptyList({ label }: { label: string }) {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="people-outline" size={24} color={colors.textTertiary} />
@@ -11,7 +11,8 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -21,6 +22,7 @@ import { buildArtistDetail } from '@/library/artistDetail';
import type { DbTrack } from '@/types/library';
export default function ArtistSongsScreen() {
const colors = useColors();
const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
@@ -79,6 +81,7 @@ export default function ArtistSongsScreen() {
}
function EmptyList({ label }: { label: string }) {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="musical-notes" size={24} color={colors.textTertiary} />
+3 -1
View File
@@ -37,7 +37,8 @@ import {
PullSearchScrollView,
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
@@ -75,6 +76,7 @@ const ALBUM_SORT_OPTIONS: AlbumSort[] = ['artist', 'name', 'recently_added', 'ye
const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count'];
export default function LibraryScreen() {
const colors = useColors();
const router = useRouter();
const viewMode = useLibraryStore((s) => s.viewMode);
const setViewMode = useLibraryStore((s) => s.setViewMode);
+8 -3
View File
@@ -25,7 +25,8 @@ import {
} from '@/components/sheets/AppSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
@@ -52,6 +53,8 @@ function basename(path: string): string {
}
function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
<View style={styles.missingMeta}>
@@ -70,6 +73,8 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP
type Prompt = { kind: 'rename'; playlist: Playlist } | null;
export default function PlaylistScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id, from } = useLocalSearchParams<{ id: string; from?: string }>();
const handleBack = useLibraryDetailBack(from);
@@ -404,7 +409,7 @@ export default function PlaylistScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
@@ -438,4 +443,4 @@ const styles = StyleSheet.create({
paddingVertical: spacing.xs,
marginTop: spacing.sm,
},
});
}));
@@ -25,7 +25,8 @@ import {
AppSheetSection,
AppSheetTitle,
} from '@/components/sheets/AppSheet';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import { fonts, fontSize, radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlaylistStore } from '@/stores/playlistStore';
import {
DYNAMIC_PLAYLIST_PRESETS,
@@ -325,6 +326,8 @@ function DraftActions({
onCancel: () => void;
onApply: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sheetActions}>
<Pressable style={[styles.sheetButton, styles.cancelButton]} onPress={onCancel} accessibilityRole="button">
@@ -427,6 +430,8 @@ function ConditionValueEditor({
condition: DynamicPlaylistCondition;
onChange: (condition: DynamicPlaylistCondition) => void;
}) {
const styles = useStyles();
const colors = useColors();
if (condition.kind === 'text') {
return (
<BottomSheetTextInput
@@ -523,6 +528,8 @@ function ConditionEditorSheet({
onCancel: () => void;
onApply: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const draft = target.draft;
const error = validateCondition(draft);
@@ -591,6 +598,8 @@ function SortLimitSheet({
onCancel: () => void;
onApply: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const trimmedLimit = limitText.trim();
const parsedLimit = trimmedLimit ? Number(trimmedLimit) : null;
const limitValid =
@@ -661,6 +670,8 @@ function PreviewSheet({
isLoading: boolean;
onClose: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const count = preview?.track_count ?? 0;
return (
@@ -701,6 +712,8 @@ function FilterCard({
onPress: () => void;
onRemove: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const option = fieldOptionForKey(getConditionFieldKey(condition));
return (
@@ -730,6 +743,8 @@ function FilterCard({
}
export default function DynamicPlaylistEditorScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const playlistId = id ? Number(id) : null;
@@ -1175,7 +1190,7 @@ export default function DynamicPlaylistEditorScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
keyboardRoot: {
flex: 1,
backgroundColor: colors.bgPrimary,
@@ -1449,4 +1464,4 @@ const styles = StyleSheet.create({
previewMessage: {
paddingVertical: spacing.md,
},
});
}));
+111 -3
View File
@@ -14,8 +14,13 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQSlider } from '@/components/eq/EQSlider';
import { ScanProgress } from '@/components/library/ScanProgress';
import { colors, radius, spacing } from '@/theme';
import { SegmentedControl } from '@/components/SegmentedControl';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { radius, 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 { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
@@ -34,6 +39,93 @@ function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
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 (
<>
<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}
</>
);
}
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
{
mode: 'astra',
@@ -64,6 +156,8 @@ function ToggleRow({
value: boolean;
onValueChange: (v: boolean) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
@@ -99,6 +193,8 @@ function LibraryFolderSettingsRow({
disabled: boolean;
onRemove: (folder: FolderWithCount) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.folderSettingsRow}>
<Ionicons
@@ -135,6 +231,8 @@ function LibraryFolderSettingsRow({
}
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);
@@ -227,6 +325,8 @@ function LibraryFoldersSettings() {
}
export default function SettingsScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
@@ -279,6 +379,11 @@ export default function SettingsScreen() {
</Text>
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
APPEARANCE
</Text>
<AppearanceSettings />
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
LIBRARY FOLDERS
</Text>
<LibraryFoldersSettings />
@@ -462,7 +567,7 @@ export default function SettingsScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
content: {
paddingBottom: spacing.xxl,
},
@@ -580,6 +685,9 @@ const styles = StyleSheet.create({
settingTitle: {
marginBottom: spacing.xs,
},
appearanceBlock: {
marginTop: spacing.md,
},
settingNote: {
marginBottom: spacing.md,
},
@@ -607,4 +715,4 @@ const styles = StyleSheet.create({
optionDescription: {
lineHeight: 16,
},
});
}));
+31 -9
View File
@@ -40,7 +40,8 @@ import {
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
import { colors } from '@/theme';
import { useThemeStore } from '@/stores/themeStore';
import { useTheme } from '@/theme/themed';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
// widget/notification opening `now-playing`, or `recently-played`) builds `[(tabs), route]`
@@ -58,6 +59,21 @@ function PlaybackSync() {
return null;
}
/**
* Re-reads system theme inputs (OS scheme + monet wallpaper ramps) on each
* return to foreground — wallpaper changes can only happen while backgrounded.
* OS dark/light toggles are covered by the Appearance listener in themeStore.
*/
function ThemeSystemSync() {
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') useThemeStore.getState().refreshSystemInputs();
});
return () => subscription.remove();
}, []);
return null;
}
/** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */
function ScopeLifecycle() {
useScopeLifecycle();
@@ -260,7 +276,12 @@ export default function RootLayout() {
const timer = setTimeout(() => setSplashTimedOut(true), 2000);
return () => clearTimeout(timer);
}, []);
const ready = fontsLoaded || splashTimedOut;
// Theme joins the gate so the first painted frame is already in the
// persisted theme (no flash). The failsafe path paints the default theme
// and snaps once the SQLite read lands — accepted degradation.
const themeLoaded = useThemeStore((s) => s.loaded);
const theme = useTheme();
const ready = (fontsLoaded && themeLoaded) || splashTimedOut;
useEffect(() => {
if (ready) {
@@ -272,6 +293,10 @@ export default function RootLayout() {
// Library tab + playback adapters get data immediately. EQ + audio settings load
// alongside so the native EQ/gain reflect persisted prefs from the first play.
useEffect(() => {
useThemeStore
.getState()
.load()
.catch((err) => console.error('[theme] load failed', err));
useLibraryStore
.getState()
.initialize()
@@ -301,9 +326,10 @@ export default function RootLayout() {
if (!ready) return null;
return (
<GestureHandlerRootView style={styles.root}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<SafeAreaProvider>
<StatusBar style="light" />
<StatusBar style={theme.statusBarStyle} />
<ThemeSystemSync />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
@@ -314,7 +340,7 @@ export default function RootLayout() {
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bgPrimary },
contentStyle: { backgroundColor: theme.colors.bgPrimary },
}}
>
<Stack.Screen name="(tabs)" />
@@ -334,7 +360,3 @@ export default function RootLayout() {
</GestureHandlerRootView>
);
}
const styles = {
root: { flex: 1, backgroundColor: colors.bgPrimary },
} as const;
+8 -4
View File
@@ -23,10 +23,10 @@ import { AstraLogo } from '@/components/AstraLogo';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
@@ -188,6 +188,8 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
onPair: (desktop: DesktopRemoteDiscoveredDesktop) => void;
disabled: boolean;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable
style={[styles.discoveredRow, disabled && styles.buttonDisabled]}
@@ -216,6 +218,8 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
}
export default function DesktopRemoteScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { pair } = useLocalSearchParams<{ pair?: string }>();
const insets = useSafeAreaInsets();
@@ -641,7 +645,7 @@ export default function DesktopRemoteScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
flex: {
flex: 1,
},
@@ -993,4 +997,4 @@ const styles = StyleSheet.create({
paddingHorizontal: CONTENT_SIDE_PADDING,
textAlign: 'center',
},
});
}));
+6 -4
View File
@@ -15,13 +15,15 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
export default function DesktopRemoteScanScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
const [permission, requestPermission] = useCameraPermissions();
@@ -89,7 +91,7 @@ export default function DesktopRemoteScanScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
@@ -156,4 +158,4 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: spacing.sm,
},
});
}));
+8 -3
View File
@@ -18,7 +18,8 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatRelativeTime } from '@/lib/format';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
@@ -65,6 +66,8 @@ function ConflictCard({
busy: boolean;
onResolve: (resolution: DesktopSyncConflictResolution) => void;
}) {
const styles = useStyles();
const colors = useColors();
const [selectedResolution, setSelectedResolution] = useState<DesktopSyncConflictResolution | null>(null);
const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote);
const phoneSnapshot = syncPlaylistToSnapshot(conflict.local);
@@ -126,6 +129,8 @@ function ConflictCard({
}
export default function DesktopSyncScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const status = useDesktopSyncStore((s) => s.status);
const lastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
@@ -295,7 +300,7 @@ export default function DesktopSyncScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
topBar: {
marginTop: spacing.md,
marginBottom: spacing.sm,
@@ -405,4 +410,4 @@ const styles = StyleSheet.create({
disabled: {
opacity: 0.5,
},
});
}));
+8 -4
View File
@@ -15,10 +15,10 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { LastFmScrobbleProtocol } from '@/types/lastFm';
@@ -79,6 +79,8 @@ function Field({
autoCapitalize = 'none',
keyboardType = 'default',
}: FieldProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
@@ -100,6 +102,8 @@ function Field({
}
export default function LastFmEditScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const status = useLastFmSettingsStore((s) => s.status);
@@ -318,7 +322,7 @@ export default function LastFmEditScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
flex: { flex: 1 },
header: {
flexDirection: 'row',
@@ -409,4 +413,4 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
marginTop: spacing.lg,
},
});
}));
+6 -4
View File
@@ -12,10 +12,10 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { requestLastFmFlush } from '@/services/lastfm';
import type { LastFmProfileStatus } from '@/types/lastFm';
@@ -44,6 +44,8 @@ function customStatusLine(profile: LastFmProfileStatus): { text: string; tone: '
}
export default function LastFmScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const status = useLastFmSettingsStore((s) => s.status);
const authHint = useLastFmSettingsStore((s) => s.authHint);
@@ -285,7 +287,7 @@ export default function LastFmScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -416,4 +418,4 @@ const styles = StyleSheet.create({
marginTop: spacing.xl,
lineHeight: 16,
},
});
}));
+5 -4
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import { View } from 'react-native';
import { useRouter } from 'expo-router';
import { getNotificationClickRedirectPath } from '@/audio/notificationIntent';
import { colors } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
export default function NotificationClickRoute() {
const styles = useStyles();
const router = useRouter();
useEffect(() => {
@@ -26,9 +27,9 @@ export default function NotificationClickRoute() {
return <View style={styles.root} />;
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
});
}));
+6 -4
View File
@@ -31,10 +31,10 @@ import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveNavigationArtist } from '@/library/artistGrouping';
@@ -264,6 +264,8 @@ function getNowPlayingLayout(
}
export default function NowPlayingScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -1059,7 +1061,7 @@ export default function NowPlayingScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: 'transparent',
@@ -1330,4 +1332,4 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
backgroundColor: colors.glassBg,
},
});
}));
+4 -1
View File
@@ -11,7 +11,8 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
@@ -23,6 +24,7 @@ function formatCount(count: number, noun: string): string {
}
function EmptyList() {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="time-outline" size={24} color={colors.textTertiary} />
@@ -34,6 +36,7 @@ function EmptyList() {
}
export default function RecentlyPlayedScreen() {
const colors = useColors();
const router = useRouter();
const tracks = useLibraryStore((s) => s.recentlyPlayedTracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
+8 -4
View File
@@ -14,10 +14,10 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceType } from '@/types/remote';
@@ -60,6 +60,8 @@ function Field({
autoCapitalize = 'none',
keyboardType = 'default',
}: FieldProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
@@ -81,6 +83,8 @@ function Field({
}
export default function SourceEditScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const sources = useRemoteSourcesStore((s) => s.sources);
@@ -308,7 +312,7 @@ export default function SourceEditScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
flex: { flex: 1 },
header: {
flexDirection: 'row',
@@ -403,4 +407,4 @@ const styles = StyleSheet.create({
buttonDisabled: {
opacity: 0.5,
},
});
}));
+6 -4
View File
@@ -12,10 +12,10 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote';
@@ -38,6 +38,8 @@ function statusLine(
}
export default function SourcesScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const sources = useRemoteSourcesStore((s) => s.sources);
const progressById = useRemoteSourcesStore((s) => s.progressById);
@@ -196,7 +198,7 @@ export default function SourcesScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -274,4 +276,4 @@ const styles = StyleSheet.create({
rowName: {
flex: 1,
},
});
}));
+6 -8
View File
@@ -1,5 +1,5 @@
import Svg, { G, Path } from 'react-native-svg';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
/**
* Astra mark ported from desktop `astraLogoShared.ts` (same viewBox, paths,
@@ -26,11 +26,9 @@ interface AstraLogoProps {
includeBackground?: boolean;
}
export function AstraLogo({
size = 28,
color = colors.logoMain,
includeBackground = false,
}: AstraLogoProps) {
export function AstraLogo({ size = 28, color, includeBackground = false }: AstraLogoProps) {
const colors = useColors();
const mainFill = color ?? colors.logoMain;
return (
<Svg width={size} height={size} viewBox={VIEWBOX} fill="none">
{includeBackground && (
@@ -47,8 +45,8 @@ export function AstraLogo({
</G>
</G>
<G transform={MAIN_TRANSFORM}>
<Path d={LEFT_PATH} fill={color} />
<Path d={RIGHT_PATH} fill={color} />
<Path d={LEFT_PATH} fill={mainFill} />
<Path d={RIGHT_PATH} fill={mainFill} />
</G>
</Svg>
);
+6 -4
View File
@@ -1,14 +1,15 @@
import { View, StyleSheet } from 'react-native';
import { Text } from './Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import type { Track } from '@/types/audio';
/** A single mono pill (e.g. "FLAC", "24-BIT", "48.0 kHz"). */
export function Badge({ label }: { label: string }) {
const styles = useStyles();
return (
<View style={styles.badge}>
<Text variant="mono" style={styles.text}>
@@ -34,6 +35,7 @@ export function FormatBadges({
wrap?: boolean;
variant?: 'pill' | 'plain';
}) {
const styles = useStyles();
const labels: string[] = [];
if (track.format) labels.push(track.format.toUpperCase());
if (track.bitDepth) labels.push(`${track.bitDepth}-BIT`);
@@ -66,7 +68,7 @@ export function FormatBadges({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -93,6 +95,6 @@ const styles = StyleSheet.create({
fontSize: 10,
letterSpacing: 0.3,
},
});
}));
export default FormatBadges;
+8 -5
View File
@@ -12,10 +12,10 @@ import { Text } from './Text';
import { AstraLogo } from './AstraLogo';
import { SpectrumCurve } from './SpectrumCurve';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlayerStore } from '@/stores/playerStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
@@ -46,6 +46,7 @@ function MiniProgress({
duration: number;
isPlaying: boolean;
}) {
const styles = useStyles();
const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying);
const progress = duration > 0 ? Math.min(1, smoothTime / duration) : 0;
return (
@@ -61,6 +62,8 @@ function MiniProgress({
* opens the full now-playing screen.
*/
export function MiniPlayer({ visible = true }: MiniPlayerProps) {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const track = usePlayerStore((s) => s.currentTrack);
@@ -205,7 +208,7 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
pill: {
height: PILL_HEIGHT,
marginHorizontal: spacing.md,
@@ -234,7 +237,7 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(8, 10, 15, 0.24)',
backgroundColor: colors.overlayFaint,
},
row: {
flexDirection: 'row',
@@ -279,6 +282,6 @@ const styles = StyleSheet.create({
height: 2,
backgroundColor: colors.accent,
},
});
}));
export default MiniPlayer;
+4 -2
View File
@@ -14,7 +14,7 @@ import {
import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope';
import { useScopeStore } from '@/scope/scopeStore';
import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
interface OscilloscopeWaveProps {
active: boolean;
@@ -111,11 +111,13 @@ export function OscilloscopeWave({
active,
width,
height,
color = colors.accent,
color: colorProp,
lineWidth = 2,
glow = false,
edgeFade: _edgeFade = false,
}: OscilloscopeWaveProps) {
const themeColors = useColors();
const color = colorProp ?? themeColors.accent;
const viewRef = useRef<SkiaPictureView | null>(null);
const initialPicture = useMemo(
() =>
+9 -5
View File
@@ -2,14 +2,14 @@ import { useEffect } from 'react';
import {
Modal,
Pressable,
StyleSheet,
View
} from 'react-native';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -24,6 +24,8 @@ interface PlaybackTargetPickerProps {
}
export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerProps) {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const insets = useSafeAreaInsets();
const selectedTarget = usePlaybackTargetStore((s) => s.target);
@@ -114,6 +116,8 @@ function TargetRow({
selected: boolean;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
@@ -141,11 +145,11 @@ function TargetRow({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0, 0, 0, 0.58)',
backgroundColor: colors.backdrop,
},
sheet: {
borderTopLeftRadius: radius.lg,
@@ -190,6 +194,6 @@ const styles = StyleSheet.create({
flex: 1,
minWidth: 0,
},
});
}));
export default PlaybackTargetPicker;
+4 -3
View File
@@ -1,5 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
import type { TrackSourceType } from '@/types/library';
/**
@@ -9,18 +9,19 @@ import type { TrackSourceType } from '@/types/library';
export function RemoteSourceBadge({
sourceType,
size = 12,
color = colors.accent,
color,
}: {
sourceType?: TrackSourceType | null;
size?: number;
color?: string;
}) {
const colors = useColors();
if (!sourceType || sourceType === 'local') return null;
return (
<Ionicons
name="cloud"
size={size}
color={color}
color={color ?? colors.accent}
accessibilityLabel={`Streaming from ${sourceType}`}
/>
);
+5 -4
View File
@@ -1,10 +1,10 @@
import {
StyleSheet,
View,
type ViewProps
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
interface ScreenProps extends ViewProps {
/** Apply default horizontal padding. */
@@ -13,6 +13,7 @@ interface ScreenProps extends ViewProps {
/** Base screen container: black background + top safe-area inset. */
export function Screen({ children, style, padded = true, ...rest }: ScreenProps) {
const styles = useStyles();
const insets = useSafeAreaInsets();
return (
<View style={[styles.root, { paddingTop: insets.top }, style]} {...rest}>
@@ -21,7 +22,7 @@ export function Screen({ children, style, padded = true, ...rest }: ScreenProps)
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
@@ -32,6 +33,6 @@ const styles = StyleSheet.create({
padded: {
paddingHorizontal: spacing.lg,
},
});
}));
export default Screen;
+5 -5
View File
@@ -1,16 +1,15 @@
import { useRef, useState } from 'react';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent
} from 'react-native';
import { Text } from './Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { formatDuration } from '@/lib/format';
const THUMB_SIZE = 12;
@@ -30,6 +29,7 @@ const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction));
* waveform seek bar port (desktop WaveformSeekBar) replaces the visuals at M3+.
*/
export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProps) {
const styles = useStyles();
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
const [barWidth, setBarWidth] = useState(0);
// Last released seek. Displayed instead of live progress until playback
@@ -129,7 +129,7 @@ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProp
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
touchArea: {
justifyContent: 'center',
paddingVertical: spacing.md, // generous touch target around the 4px track
@@ -166,6 +166,6 @@ const styles = StyleSheet.create({
timeActive: {
color: colors.accentText,
},
});
}));
export default SeekBar;
+12 -9
View File
@@ -12,10 +12,10 @@ import Animated, {
withTiming
} from 'react-native-reanimated';
import {
colors,
fonts,
radius
radius,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { motion } from '@/theme/motion';
const THUMB_INSET = 3;
@@ -37,6 +37,7 @@ interface SegmentedControlProps {
* accent via interpolateColor on Animated.Text. Spring-free per theme/motion.
*/
export function SegmentedControl({ segments, value, onChange }: SegmentedControlProps) {
const styles = useStyles();
const count = segments.length;
const activeIndex = Math.max(
0,
@@ -86,6 +87,8 @@ function SegmentButton({
focused: boolean;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
// 0 = inactive, 1 = active; drives the label colour cross-fade.
const progress = useSharedValue(focused ? 1 : 0);
@@ -93,12 +96,12 @@ function SegmentButton({
progress.value = withTiming(focused ? 1 : 0, motion.quick);
}, [focused, progress]);
// Locals so the worklet captures plain strings: a theme switch re-renders,
// the captured values change, and Reanimated rebuilds the worklet.
const inactiveColor = colors.textSecondary;
const activeColor = colors.accentTextStrong;
const labelStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[colors.textSecondary, colors.accentTextStrong],
),
color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]),
}));
return (
@@ -115,7 +118,7 @@ function SegmentButton({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
track: {
flexDirection: 'row',
backgroundColor: colors.glassBg,
@@ -147,6 +150,6 @@ const styles = StyleSheet.create({
fontSize: 12,
fontFamily: fonts.sans.medium,
},
});
}));
export default SegmentedControl;
+6 -3
View File
@@ -13,7 +13,7 @@ import {
type SkPicture
} from '@shopify/react-native-skia';
import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
interface SpectrumCurveProps {
/** Normalized magnitudes in [0,1] for static rendering. Live rendering ignores this. */
@@ -305,16 +305,19 @@ export function SpectrumCurve({
dbMin = DISPLAY_DB_MIN,
dbMax = DISPLAY_DB_MAX,
tiltDbPerOctave = TILT_DB_PER_OCT,
color = colors.accent,
color: colorProp,
lineWidth = 2,
lineOpacity = 1,
fillOpacity = 1,
glow = false,
glowOpacity = 0.18,
edgeFade = false,
edgeFadeColor = colors.bgPrimary,
edgeFadeColor: edgeFadeColorProp,
edgeFadeWidth = 28,
}: SpectrumCurveProps) {
const themeColors = useColors();
const color = colorProp ?? themeColors.accent;
const edgeFadeColor = edgeFadeColorProp ?? themeColors.bgPrimary;
const viewRef = useRef<SkiaPictureView | null>(null);
const activePointCount = Math.max(2, Math.floor(width));
const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS);
+2 -1
View File
@@ -16,7 +16,7 @@ import Animated, {
useSharedValue,
withTiming
} from 'react-native-reanimated';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
import { motion } from '@/theme/motion';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
@@ -59,6 +59,7 @@ export function SwipeableRow({
enabled = true,
children,
}: SwipeableRowProps) {
const colors = useColors();
const tx = useSharedValue(0);
const armed = useSharedValue(false);
const [rowWidth, setRowWidth] = useState(0);
+12 -9
View File
@@ -15,11 +15,11 @@ import Animated, {
} from 'react-native-reanimated';
import { MiniPlayer } from './MiniPlayer';
import {
colors,
fonts,
layout,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { motion } from '@/theme/motion';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
@@ -54,6 +54,7 @@ interface TabBarProps {
* logic stays in the layout's `tabBar` callback.
*/
export function TabBar({ items, onPress }: TabBarProps) {
const styles = useStyles();
const insets = useSafeAreaInsets();
const tabs = items.filter((item) => TAB_META[item.name]);
const homeFocused = items.some((item) => item.name === 'index' && item.focused);
@@ -158,6 +159,8 @@ interface TabButtonProps {
* theme/motion.
*/
function TabButton({ meta, focused, onPress }: TabButtonProps) {
const styles = useStyles();
const colors = useColors();
// 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom.
const progress = useSharedValue(focused ? 1 : 0);
// 0 = at rest, 1 = finger down.
@@ -171,12 +174,12 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) {
transform: [{ scale: 1 - press.value * 0.12 }],
}));
const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
// Locals so the worklet captures plain strings: a theme switch re-renders,
// the captured values change, and Reanimated rebuilds the worklet.
const inactiveColor = colors.textTertiary;
const activeColor = colors.accent;
const labelStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[colors.textTertiary, colors.accent],
),
color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]),
}));
return (
@@ -204,7 +207,7 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
wrap: {
backgroundColor: colors.bgSecondary,
},
@@ -237,6 +240,6 @@ const styles = StyleSheet.create({
fontSize: 10,
fontFamily: fonts.sans.regular,
},
});
}));
export default TabBar;
+5 -5
View File
@@ -1,14 +1,13 @@
import type { ReactNode } from 'react';
import {
StyleSheet,
Text as RNText,
type TextProps as RNTextProps
} from 'react-native';
import {
colors,
fonts,
fontSize
fontSize,
} from '@/theme';
import { createThemedStyles } from '@/theme/themed';
type Variant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono';
@@ -55,6 +54,7 @@ function collectText(node: ReactNode): string {
/** Themed Text — applies Astra fonts/colors. Import this instead of RN's Text. */
export function Text({ variant = 'body', color, style, ...rest }: TextProps) {
const styles = useStyles();
const fallback = NON_LATIN.test(collectText(rest.children));
return (
<RNText
@@ -72,7 +72,7 @@ export function Text({ variant = 'body', color, style, ...rest }: TextProps) {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
title: {
fontSize: fontSize.xxl,
color: colors.textPrimary,
@@ -97,6 +97,6 @@ const styles = StyleSheet.create({
fontSize: fontSize.sm,
color: colors.textSecondary,
},
});
}));
export default Text;
+6 -4
View File
@@ -1,6 +1,5 @@
import { useState } from 'react';
import {
StyleSheet,
Pressable,
View
} from 'react-native';
@@ -8,7 +7,8 @@ import { Ionicons } from '@expo/vector-icons';
import { Text } from './Text';
import { SpectrumCurve } from './SpectrumCurve';
import { OscilloscopeWave } from './OscilloscopeWave';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useScopeActive } from '@/scope/scopeStore';
const CANVAS_HEIGHT = 96;
@@ -38,6 +38,8 @@ export function Visualizer({
mode: controlledMode,
edgeFade = false,
}: VisualizerProps) {
const styles = useStyles();
const colors = useColors();
const [uncontrolledMode, setUncontrolledMode] = useState<Mode>('spectrum');
const mode = controlledMode ?? uncontrolledMode;
const scopeActive = useScopeActive();
@@ -103,7 +105,7 @@ export function Visualizer({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
wrap: {
paddingVertical: spacing.xs,
},
@@ -121,6 +123,6 @@ const styles = StyleSheet.create({
letterSpacing: 1.5,
fontSize: 10,
},
});
}));
export default Visualizer;
+6 -4
View File
@@ -5,7 +5,6 @@ import {
useState
} from 'react';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent
@@ -18,7 +17,8 @@ import {
rect
} from '@shopify/react-native-skia';
import { Text } from './Text';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatDuration } from '@/lib/format';
import { downsampleWaveform, getWaveform } from '@/scope/waveform';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
@@ -58,6 +58,8 @@ export function WaveformSeekBar({
touchPadding = spacing.md,
trackPath,
}: WaveformSeekBarProps) {
const styles = useStyles();
const colors = useColors();
const [scrubFraction, setScrubFraction] = useState<number | null>(null);
const [barWidth, setBarWidth] = useState(0);
const pendingSeek = usePlayerStore((s) => s.pendingSeek);
@@ -199,7 +201,7 @@ export function WaveformSeekBar({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
touchArea: {
justifyContent: 'center',
},
@@ -215,6 +217,6 @@ const styles = StyleSheet.create({
timeActive: {
color: colors.accentText,
},
});
}));
export default WaveformSeekBar;
+6 -4
View File
@@ -7,10 +7,10 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { EQBand } from '@/types/audio';
import {
EQ_MAX_FREQUENCY,
@@ -41,6 +41,8 @@ export type EQEditableValue = 'frequency' | 'gain' | 'Q';
/** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */
export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) {
const styles = useStyles();
const colors = useColors();
if (!band) {
return (
<View style={styles.card}>
@@ -108,7 +110,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
card: {
borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
@@ -140,6 +142,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: spacing.sm,
},
});
}));
export default BandDetailPanel;
+7 -5
View File
@@ -6,10 +6,10 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { EQBand } from '@/types/audio';
import {
formatFreq,
@@ -27,6 +27,8 @@ interface BandStripProps {
/** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */
export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) {
const styles = useStyles();
const colors = useColors();
return (
<ScrollView
horizontal
@@ -46,7 +48,7 @@ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: Band
</Text>
<Text
variant="label"
style={[styles.gain, { color: band.enabled ? gainColor(band.gain) : colors.textTertiary }]}
style={[styles.gain, { color: band.enabled ? gainColor(band.gain, colors) : colors.textTertiary }]}
>
{formatGain(band.gain)}
</Text>
@@ -62,7 +64,7 @@ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: Band
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
content: {
gap: spacing.sm,
paddingVertical: spacing.xs,
@@ -94,6 +96,6 @@ const styles = StyleSheet.create({
gain: {
fontSize: 15,
},
});
}));
export default BandStrip;
+5 -3
View File
@@ -20,7 +20,7 @@ import {
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import { SpectrumCurve } from '@/components/SpectrumCurve';
import { colors } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { EQBand } from '@/types/audio';
import {
FREQ_TICKS,
@@ -59,6 +59,8 @@ export function EQGraph({
onSelectBand,
onChangeBand,
}: EQGraphProps) {
const styles = useStyles();
const colors = useColors();
const [size, setSize] = useStableSize();
const width = size.width;
const height = size.height;
@@ -302,7 +304,7 @@ function useStableSize(): [
return [size, set];
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
container: {
flex: 1,
borderRadius: 16,
@@ -329,6 +331,6 @@ const styles = StyleSheet.create({
textAlign: 'center',
color: colors.textTertiary,
},
});
}));
export default EQGraph;
+5 -4
View File
@@ -8,10 +8,10 @@ import {
} from 'react-native';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles } from '@/theme/themed';
const THUMB = 16;
@@ -42,6 +42,7 @@ export function EQSlider({
disabled,
onValuePress,
}: EQSliderProps) {
const styles = useStyles();
const [width, setWidth] = useState(0);
const [active, setActive] = useState(false);
const widthRef = useRef(0);
@@ -136,7 +137,7 @@ export function EQSlider({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -195,6 +196,6 @@ const styles = StyleSheet.create({
textAlign: 'right',
color: colors.textPrimary,
},
});
}));
export default EQSlider;
+6 -4
View File
@@ -8,11 +8,11 @@ import {
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import {
colors,
fonts,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { EqSheet } from './EqSheet';
interface EQValueEditSheetProps {
@@ -39,6 +39,8 @@ export function EQValueEditSheet({
onApply,
onClose,
}: EQValueEditSheetProps) {
const styles = useStyles();
const colors = useColors();
const [value, setValue] = useState(initialValue);
const trimmed = value.trim();
const parsed = trimmed.length > 0 ? parseValue(trimmed) : null;
@@ -97,7 +99,7 @@ export function EQValueEditSheet({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
@@ -156,6 +158,6 @@ const styles = StyleSheet.create({
applyDisabled: {
opacity: 0.4,
},
});
}));
export default EQValueEditSheet;
+7 -4
View File
@@ -1,6 +1,7 @@
import { StyleSheet, View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { EQ_MAX_GAIN_DB, EQ_MIN_GAIN_DB } from '@/audio/eq';
import { GRAPHIC_BANDS } from '@/audio/graphicEq';
import { GraphicResponseCurve } from './GraphicResponseCurve';
@@ -25,6 +26,8 @@ interface GraphicEQPanelProps {
* curve's evenly spaced band positions.
*/
export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.container}>
<View style={styles.metaRow}>
@@ -32,7 +35,7 @@ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelP
<Text
key={def.key}
variant="mono"
style={[styles.value, { color: gainColor(gains[i] ?? 0) }]}
style={[styles.value, { color: gainColor(gains[i] ?? 0, colors) }]}
>
{formatGain(gains[i] ?? 0)}
</Text>
@@ -71,7 +74,7 @@ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelP
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
container: {
flex: 1,
},
@@ -100,6 +103,6 @@ const styles = StyleSheet.create({
caption: {
color: colors.textTertiary,
},
});
}));
export default GraphicEQPanel;
+2 -1
View File
@@ -12,7 +12,7 @@ import {
Skia,
type SkPath
} from '@shopify/react-native-skia';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
import type { EQBand } from '@/types/audio';
import {
EQ_MAX_FREQUENCY,
@@ -39,6 +39,7 @@ interface GraphicResponseCurveProps {
* the chrome).
*/
export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurveProps) {
const colors = useColors();
const [size, setSize] = useState({ width: 0, height: 0 });
const width = size.width;
const height = size.height;
+3 -1
View File
@@ -1,7 +1,8 @@
import { Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import type { EQPreset } from '@/types/audio';
import {
EqSheet,
@@ -35,6 +36,7 @@ export function PresetSheet({
onSaveNew,
onClose,
}: PresetSheetProps) {
const colors = useColors();
const builtIn = presets.filter((p) => !p.isCustom);
const custom = presets.filter((p) => p.isCustom);
+6 -4
View File
@@ -7,11 +7,11 @@ import {
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import {
colors,
fonts,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { EqSheet } from './EqSheet';
interface SavePresetSheetProps {
@@ -22,6 +22,8 @@ interface SavePresetSheetProps {
/** Name + save a custom preset from the current bands/preamp. */
export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) {
const styles = useStyles();
const colors = useColors();
const [name, setName] = useState(defaultName);
const trimmed = name.trim();
@@ -70,7 +72,7 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
title: {
marginTop: spacing.xs,
marginBottom: spacing.md,
@@ -109,6 +111,6 @@ const styles = StyleSheet.create({
saveDisabled: {
opacity: 0.4,
},
});
}));
export default SavePresetSheet;
+6 -4
View File
@@ -1,11 +1,11 @@
import { useRef, useState } from 'react';
import {
StyleSheet,
View,
type GestureResponderEvent,
type LayoutChangeEvent
} from 'react-native';
import { colors, radius } from '@/theme';
import { radius } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
// Vertical fader cap (mixer-style): tall capsule with a horizontal grip line
// marking the exact value position. Half its height overshoots the rail at the
@@ -37,6 +37,8 @@ const clamp01 = (f: number) => Math.min(1, Math.max(0, f));
* same code as the readouts).
*/
export function VerticalEQSlider({ label, value, min, max, onChange }: VerticalEQSliderProps) {
const styles = useStyles();
const colors = useColors();
const [height, setHeight] = useState(0);
const [active, setActive] = useState(false);
const heightRef = useRef(0);
@@ -106,7 +108,7 @@ export function VerticalEQSlider({ label, value, min, max, onChange }: VerticalE
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
touch: {
flex: 1,
alignItems: 'center',
@@ -151,6 +153,6 @@ const styles = StyleSheet.create({
borderRadius: 1,
backgroundColor: colors.bgSecondary,
},
});
}));
export default VerticalEQSlider;
+2 -2
View File
@@ -1,6 +1,6 @@
// Shared EQ value formatting for the band strip + detail panel.
import { colors } from '@/theme';
import type { Palette } from '@/theme/palettes';
import type { EQBandType } from '@/types/audio';
export function formatFreq(hz: number): string {
@@ -25,7 +25,7 @@ export function formatGain(db: number): string {
return `${db > 0 ? '+' : ''}${db.toFixed(1)}`;
}
export function gainColor(db: number): string {
export function gainColor(db: number, colors: Palette): string {
if (db > 0.05) return colors.accentText;
if (db < -0.05) return colors.warning;
return colors.textTertiary;
+5 -4
View File
@@ -7,14 +7,15 @@ import { Image } from 'expo-image';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) {
const styles = useStyles();
const artUri = albumArtworkSource(album);
return (
<Pressable style={styles.item} onPress={onPress} accessibilityRole="button">
@@ -41,7 +42,7 @@ export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () =>
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
item: {
flex: 1,
marginBottom: spacing.lg,
@@ -64,4 +65,4 @@ const styles = StyleSheet.create({
title: {
fontSize: 14,
},
});
}));
+6 -4
View File
@@ -7,15 +7,17 @@ import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { albumArtworkSource } from '@/library/artwork';
import type { Album } from '@/types/library';
/** Compact album list row (search results) — the grid uses AlbumGridItem. */
export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) {
const styles = useStyles();
const colors = useColors();
const artUri = albumArtworkSource(album);
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
@@ -43,7 +45,7 @@ export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -71,4 +73,4 @@ const styles = StyleSheet.create({
flex: 1,
gap: 2,
},
});
}));
+7 -4
View File
@@ -4,7 +4,9 @@ import { StyleSheet, View, type LayoutChangeEvent } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
import { rgbaFromHex } from '@/theme/colorUtils';
import { tickHaptic } from '@/lib/haptics';
import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture';
import { RAIL_LETTERS } from '@/lib/letterIndex';
@@ -29,6 +31,7 @@ interface AlphabetRailProps {
* scroll-top never arms the search indicator.
*/
export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProps) {
const styles = useStyles();
const pullSearchRef = usePullSearchGestureRef();
const [scrubLetter, setScrubLetter] = useState<string | null>(null);
const lastLetter = useSharedValue('');
@@ -128,7 +131,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
wrap: {
position: 'absolute',
top: 0,
@@ -144,7 +147,7 @@ const styles = StyleSheet.create({
width: 16,
paddingVertical: RAIL_PAD,
alignItems: 'center',
backgroundColor: 'rgba(8, 10, 15, 0.35)',
backgroundColor: rgbaFromHex(colors.bgPrimary, 0.35),
borderRadius: radius.pill,
},
cell: {
@@ -189,4 +192,4 @@ const styles = StyleSheet.create({
lineHeight: 30,
color: colors.accentTextStrong,
},
});
}));
+6 -4
View File
@@ -7,15 +7,17 @@ import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
spacing,
radius
radius,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
/** 2-column grid cell: square art (2x2 album mosaic when available) + counts, matching the album grid. */
export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) {
const styles = useStyles();
const colors = useColors();
const useMosaic = artist.artwork_hashes.length >= 4;
const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1);
@@ -58,7 +60,7 @@ export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: (
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
item: {
flex: 1,
marginBottom: spacing.lg,
@@ -87,4 +89,4 @@ const styles = StyleSheet.create({
name: {
fontSize: 14,
},
});
}));
+6 -4
View File
@@ -7,14 +7,16 @@ import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { artworkUri } from '@/library/artwork';
import type { Artist } from '@/types/library';
export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => void }) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.art}>
@@ -41,7 +43,7 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () =>
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -66,4 +68,4 @@ const styles = StyleSheet.create({
meta: {
flex: 1,
},
});
}));
+8 -4
View File
@@ -30,10 +30,10 @@ import {
} from '@shopify/react-native-skia';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
// Collapsing detail header. An absolute container whose height shrinks with the
// scroll and clips its faded content, so the track list (padded to the expanded
@@ -97,6 +97,8 @@ export function useDetailCollapse() {
}
function BottomFade() {
const styles = useStyles();
const colors = useColors();
const [width, setWidth] = useState(0);
return (
<View style={styles.fade} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
@@ -152,6 +154,8 @@ export function CollapsingHeader({
expandedHeight: number;
onHeroBlockLayout: (e: LayoutChangeEvent) => void;
}) {
const styles = useStyles();
const colors = useColors();
const insets = useSafeAreaInsets();
const { width: W } = useWindowDimensions();
const dist = expandedHeight - BAR_H;
@@ -330,7 +334,7 @@ export function CollapsingHeader({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
container: {
position: 'absolute',
top: 0,
@@ -481,4 +485,4 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
});
}));
+6 -5
View File
@@ -1,5 +1,4 @@
import {
StyleSheet,
View,
Pressable
} from 'react-native';
@@ -7,12 +6,14 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
export function EmptyLibrary() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
return (
@@ -34,7 +35,7 @@ export function EmptyLibrary() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
empty: {
flex: 1,
alignItems: 'center',
@@ -63,4 +64,4 @@ const styles = StyleSheet.create({
color: colors.bgPrimary,
fontWeight: '600',
},
});
}));
+10 -4
View File
@@ -32,10 +32,10 @@ import {
} from '@/library/folderTree';
import { formatDuration } from '@/lib/format';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import type { DbTrack } from '@/types/library';
@@ -58,6 +58,8 @@ function FolderRow({
onShuffle: (node: FolderTreeNode) => void;
onOpenActions: (node: FolderTreeNode) => void;
}) {
const styles = useStyles();
const colors = useColors();
const { node, depth, isExpanded } = row;
const play = (event: GestureResponderEvent) => {
@@ -132,6 +134,8 @@ function FolderTrackRow({
active: boolean;
onOpenActions: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const index = row.folderTracks.findIndex((track) => track.path === row.track.path);
const playFolderTrack = () => {
@@ -180,6 +184,8 @@ function FolderTrackRow({
}
export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) {
const styles = useStyles();
const colors = useColors();
const folders = useLibraryStore((s) => s.folders);
const tracks = useLibraryStore((s) => s.tracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
@@ -306,7 +312,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps)
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
listContent: {
paddingBottom: spacing.xxl,
},
@@ -398,4 +404,4 @@ const styles = StyleSheet.create({
textAlign: 'center',
maxWidth: 260,
},
});
}));
+6 -4
View File
@@ -7,10 +7,10 @@ import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { artworkUri } from '@/library/artwork';
export function PlaylistRow({
@@ -37,6 +37,8 @@ export function PlaylistRow({
onPress: () => void;
onLongPress?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable
style={styles.row}
@@ -84,7 +86,7 @@ export function PlaylistRow({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -120,4 +122,4 @@ const styles = StyleSheet.create({
title: {
flexShrink: 1,
},
});
}));
+6 -4
View File
@@ -20,10 +20,10 @@ import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { Playlist } from '@/types/playlist';
@@ -46,6 +46,8 @@ export function PlaylistsView({
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollEventThrottle?: number;
}) {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const playlists = usePlaylistStore((s) => s.playlists);
const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length);
@@ -273,7 +275,7 @@ export function PlaylistsView({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
container: {
flex: 1,
},
@@ -308,4 +310,4 @@ const styles = StyleSheet.create({
borderRadius: radius.pill,
backgroundColor: colors.accentGlow,
},
});
}));
+7 -4
View File
@@ -1,10 +1,13 @@
import { StyleSheet, View } from 'react-native';
import { View } from 'react-native';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
/** Thin accent bar + caption shown under the library header while scanning. */
export function ScanProgress() {
const styles = useStyles();
const colors = useColors();
const isScanning = useLibraryStore((s) => s.isScanning);
const progress = useLibraryStore((s) => s.scanProgress);
@@ -42,7 +45,7 @@ export function ScanProgress() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
container: {
gap: spacing.xs,
marginBottom: spacing.md,
@@ -61,4 +64,4 @@ const styles = StyleSheet.create({
width: '100%',
opacity: 0.35,
},
});
}));
@@ -5,7 +5,8 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
interface SelectionActionBarProps {
count: number;
@@ -21,6 +22,7 @@ export function SelectionActionBar({
onAddToQueue,
onAddToPlaylist,
}: SelectionActionBarProps) {
const styles = useStyles();
const disabled = count === 0;
return (
<View style={styles.bar}>
@@ -62,6 +64,8 @@ function BarButton({
disabled: boolean;
onPress: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable
style={({ pressed }) => [
@@ -82,7 +86,7 @@ function BarButton({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
bar: {
flexDirection: 'row',
borderTopColor: colors.glassBorder,
@@ -106,4 +110,4 @@ const styles = StyleSheet.create({
label: {
color: colors.accent,
},
});
}));
+6 -4
View File
@@ -13,10 +13,10 @@ import { FormatBadges } from '@/components/FormatBadge';
import { RemoteSourceBadge } from '@/components/RemoteSourceBadge';
import { SwipeableRow } from '@/components/SwipeableRow';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatDuration } from '@/lib/format';
import { trackArtworkThumbSource } from '@/library/artwork';
import { dbTrackToTrack } from '@/library/trackAdapter';
@@ -58,6 +58,8 @@ export function TrackRow({
selected?: boolean;
onToggleSelect?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
// Key the artwork by hash (local) or identity path (remote) so the error fallback
// and FlashList recycling work for both.
const artKey = track.source_type !== 'local' ? track.path : track.artwork_hash;
@@ -174,7 +176,7 @@ export function TrackRow({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
row: {
flexDirection: 'row',
alignItems: 'center',
@@ -258,4 +260,4 @@ const styles = StyleSheet.create({
actionsButtonPressed: {
backgroundColor: colors.glassBg,
},
});
}));
+18 -8
View File
@@ -39,10 +39,10 @@ import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import { SwipeableRow } from '@/components/SwipeableRow';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { motion } from '@/theme/motion';
import { dragArmHaptic, tickHaptic } from '@/lib/haptics';
import { useQueueStore } from '@/stores/queueStore';
@@ -139,6 +139,8 @@ interface QueueTrayProps {
}
export function QueueTray({ onClose }: QueueTrayProps) {
const styles = useStyles();
const colors = useColors();
const insets = useSafeAreaInsets();
const { height: windowHeight } = useWindowDimensions();
const snapPoints = useMemo(() => ['58%', '100%'], []);
@@ -530,7 +532,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
)}
</View>
),
[isLoadingQueue]
[isLoadingQueue, colors, styles]
);
const selectedCount = visibleSelectedKeys.size;
@@ -645,6 +647,7 @@ export function QueueTray({ onClose }: QueueTrayProps) {
}
const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) {
const styles = useStyles();
return (
<View style={styles.art}>
{uri ? (
@@ -711,6 +714,8 @@ const QueueRow = memo(function QueueRow({
onRemoveIndex,
onToggleSelectKey,
}: QueueRowProps) {
const styles = useStyles();
const colors = useColors();
const entryKey = entry.key;
const title = trackTitle(entry.track);
const artist = trackArtist(entry.track);
@@ -791,13 +796,18 @@ const QueueRow = memo(function QueueRow({
};
});
// Locals so the worklet captures plain strings: a theme switch re-renders,
// the captured values change, and Reanimated rebuilds the worklet.
const dragSurface = colors.bgTertiary;
const selectedSurface = colors.glassHighlight;
const restSurface = colors.bgSecondary;
const rowSurfaceStyle = useAnimatedStyle(() => ({
backgroundColor:
dActive.value && dKey.value === entryKey
? colors.bgTertiary
? dragSurface
: selected
? colors.glassHighlight
: colors.bgSecondary,
? selectedSurface
: restSurface,
}));
const rowContent = (
@@ -894,7 +904,7 @@ const QueueRow = memo(function QueueRow({
);
});
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
@@ -1058,6 +1068,6 @@ const styles = StyleSheet.create({
actionTextDestructive: {
color: colors.warning,
},
});
}));
export default QueueTray;
+8 -5
View File
@@ -6,7 +6,7 @@
// this app's screen setups (see queue-tray-sheet gotcha).
import { useCallback, useEffect, useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Pressable, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import BottomSheet, {
BottomSheetBackdrop,
@@ -15,7 +15,8 @@ import BottomSheet, {
} from '@gorhom/bottom-sheet';
import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatDuration } from '@/lib/format';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteQueueItem } from '@/types/desktopRemote';
@@ -25,6 +26,8 @@ interface RemoteQueueSheetProps {
}
export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
const styles = useStyles();
const colors = useColors();
const queue = useDesktopRemoteStore((s) => s.queue);
const snapPoints = useMemo(() => ['58%', '100%'], []);
const renderFlashListScrollComponent = useBottomSheetScrollableCreator();
@@ -92,7 +95,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
) : null}
</Pressable>
),
[playItem]
[playItem, colors, styles]
);
return (
@@ -131,7 +134,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderRadius: radius.lg,
@@ -170,4 +173,4 @@ const styles = StyleSheet.create({
paddingVertical: spacing.xl,
alignItems: 'center',
},
});
}));
+6 -4
View File
@@ -33,10 +33,10 @@ import {
} from 'react-native-reanimated';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { commitHaptic, tickHaptic } from '@/lib/haptics';
const OPEN_THRESHOLD = 76;
@@ -133,6 +133,8 @@ export function PullSearchGesture({
atTop: boolean;
onOpen: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const [pull, setPull] = useState(0);
const [armed, setArmed] = useState(false);
const [dragging, setDragging] = useState(false);
@@ -269,7 +271,7 @@ export function PullSearchGesture({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
},
@@ -293,4 +295,4 @@ const styles = StyleSheet.create({
shadowOffset: { width: 0, height: 8 },
elevation: 10,
},
});
}));
+16 -7
View File
@@ -22,12 +22,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
import {
colors,
fonts,
fontSize,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { rgbaFromHex } from '@/theme/colorUtils';
import { enqueueTop, playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import {
@@ -336,6 +337,7 @@ function resultIcon(result: SearchResult): IconName {
}
function HighlightedLabel({ text, query }: { text: string; query: string }) {
const styles = useStyles();
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase().trim();
@@ -390,6 +392,8 @@ function HighlightedLabel({ text, query }: { text: string; query: string }) {
}
function ResultThumb({ result }: { result: SearchResult }) {
const styles = useStyles();
const colors = useColors();
const uri =
result.kind === 'track'
? trackArtworkThumbSource(result.track)
@@ -431,6 +435,8 @@ function ResultRow({
onPress: () => void;
onQueueTrack: (track: DbTrack) => void;
}) {
const styles = useStyles();
const colors = useColors();
const isTrack = result.kind === 'track';
const isShowMode = result.kind === 'show-all' || result.kind === 'show-top';
@@ -481,6 +487,8 @@ function QuickSearchPanel({
initialQuery: string;
onClose: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
@@ -978,6 +986,7 @@ function QuickSearchPanel({
}
export function QuickSearchOverlay() {
const styles = useStyles();
const isOpen = useSearchStore((s) => s.isQuickSearchOpen);
const initialQuery = useSearchStore((s) => s.initialQuery);
const openVersion = useSearchStore((s) => s.openVersion);
@@ -1006,11 +1015,11 @@ export function QuickSearchOverlay() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
modalRoot: {
flex: 1,
alignItems: 'center',
backgroundColor: 'rgba(2, 4, 8, 0.66)',
backgroundColor: colors.backdrop,
paddingHorizontal: spacing.md,
},
panel: {
@@ -1077,7 +1086,7 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
},
resultRowActive: {
backgroundColor: 'rgba(91, 138, 255, 0.12)',
backgroundColor: rgbaFromHex(colors.accent, 0.12),
},
showModeRow: {
marginTop: spacing.sm,
@@ -1119,7 +1128,7 @@ const styles = StyleSheet.create({
},
highlight: {
color: colors.accentTextStrong,
backgroundColor: 'rgba(91, 138, 255, 0.22)',
backgroundColor: rgbaFromHex(colors.accent, 0.22),
borderRadius: 2,
},
queueButton: {
@@ -1143,4 +1152,4 @@ const styles = StyleSheet.create({
emptyText: {
textAlign: 'center',
},
});
}));
@@ -0,0 +1,74 @@
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { spacing } from '@/theme';
import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents';
import { createThemedStyles, useColors } from '@/theme/themed';
const SWATCH_SIZE = 36;
interface AccentSwatchRowProps {
value: AccentId;
onChange: (id: AccentId) => void;
}
/** Circular accent swatches; the selected one gets a ring + checkmark. */
export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.wrap}>
<View style={styles.row}>
{ACCENT_IDS.map((id) => {
const selected = id === value;
return (
<Pressable
key={id}
onPress={() => onChange(id)}
accessibilityRole="radio"
accessibilityState={{ selected }}
accessibilityLabel={`${ACCENTS[id].label} accent`}
style={[
styles.swatch,
{ backgroundColor: ACCENTS[id].base },
selected && styles.swatchSelected,
]}
hitSlop={4}
>
{selected ? (
<Ionicons name="checkmark" size={18} color={colors.bgPrimary} />
) : null}
</Pressable>
);
})}
</View>
<Text variant="caption" color={colors.textSecondary}>
Accent · {ACCENTS[value].label}
</Text>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
wrap: {
gap: spacing.sm,
},
row: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.md,
},
swatch: {
width: SWATCH_SIZE,
height: SWATCH_SIZE,
borderRadius: SWATCH_SIZE / 2,
alignItems: 'center',
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
swatchSelected: {
borderWidth: 2,
borderColor: colors.textPrimary,
},
}));
+7 -5
View File
@@ -8,10 +8,10 @@ import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
export interface ActionSheetItem {
key: string;
@@ -37,6 +37,8 @@ export function ActionSheet({
items: ActionSheetItem[];
onClose: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const insets = useSafeAreaInsets();
return (
@@ -88,10 +90,10 @@ export function ActionSheet({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backgroundColor: colors.backdrop,
justifyContent: 'flex-end',
},
card: {
@@ -126,4 +128,4 @@ const styles = StyleSheet.create({
itemLabel: {
flex: 1,
},
});
}));
+10 -5
View File
@@ -1,6 +1,5 @@
import { useCallback, type ReactNode } from 'react';
import {
StyleSheet,
Pressable,
View
} from 'react-native';
@@ -13,12 +12,13 @@ import BottomSheet, {
} from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
const styles = useStyles();
const insets = useSafeAreaInsets();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -51,6 +51,7 @@ export function AppSheet({ onClose, children }: { onClose: () => void; children:
}
export function AppSheetSection({ label }: { label: string }) {
const styles = useStyles();
return (
<Text variant="caption" style={styles.section}>
{label}
@@ -59,6 +60,8 @@ export function AppSheetSection({ label }: { label: string }) {
}
export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: string }) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.titleBlock}>
<Text variant="heading" numberOfLines={1} style={styles.title}>
@@ -90,6 +93,8 @@ export function AppSheetItem({
onPress,
trailing,
}: AppSheetItemProps) {
const styles = useStyles();
const colors = useColors();
const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary;
return (
@@ -112,7 +117,7 @@ export function AppSheetItem({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
sheetBg: {
backgroundColor: colors.bgSecondary,
borderTopLeftRadius: radius.lg,
@@ -157,6 +162,6 @@ const styles = StyleSheet.create({
itemLabel: {
flex: 1,
},
});
}));
export default AppSheet;
@@ -12,11 +12,11 @@ import {
AppSheetTitle
} from '@/components/sheets/AppSheet';
import {
colors,
fonts,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlaylistStore } from '@/stores/playlistStore';
import type { DbTrack } from '@/types/library';
@@ -40,6 +40,8 @@ export function PlaylistPickerSheet({
onBackToMenu,
onAdded,
}: PlaylistPickerSheetProps) {
const styles = useStyles();
const colors = useColors();
const [step, setStep] = useState<'pick' | 'create'>('pick');
const [playlistName, setPlaylistName] = useState('');
const playlists = usePlaylistStore((s) => s.playlists);
@@ -123,7 +125,7 @@ export function PlaylistPickerSheet({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
empty: {
paddingVertical: spacing.sm,
},
@@ -161,4 +163,4 @@ const styles = StyleSheet.create({
createDisabled: {
opacity: 0.4,
},
});
}));
+7 -5
View File
@@ -8,12 +8,12 @@ import {
} from 'react-native';
import { Text } from '@/components/Text';
import {
colors,
fonts,
fontSize,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
interface TextPromptModalProps {
visible: boolean;
@@ -40,6 +40,8 @@ function TextPromptModalInner({
onSubmit,
onClose,
}: TextPromptModalProps) {
const styles = useStyles();
const colors = useColors();
const [value, setValue] = useState(initialValue);
const trimmed = value.trim();
@@ -89,10 +91,10 @@ function TextPromptModalInner({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backgroundColor: colors.backdrop,
justifyContent: 'center',
padding: spacing.xl,
},
@@ -130,4 +132,4 @@ const styles = StyleSheet.create({
actionDisabled: {
opacity: 0.4,
},
});
}));
+10 -3
View File
@@ -1,7 +1,8 @@
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatRelativeTime } from '@/lib/format';
import {
buildSyncPlaylistEntryDiff,
@@ -77,6 +78,8 @@ function TrackDiffRow({
side: 'desktop' | 'phone';
previewResolution: DesktopSyncConflictResolution | null;
}) {
const styles = useStyles();
const colors = useColors();
const subtitle = [row.artist, row.album].filter((part) => part.trim().length > 0).join(' · ');
const previewLabel = previewStatusLabel(row, side, previewResolution);
const moveLabel = row.status === 'moved' && !previewLabel ? moveStatusLabel(row, side) : null;
@@ -125,6 +128,8 @@ function SideTrackList({
previewResolution: DesktopSyncConflictResolution | null;
maxRows: number;
}) {
const styles = useStyles();
const colors = useColors();
const rows = [...sideOnlyRows, ...movedRows].slice(0, maxRows);
const hiddenCount = Math.max(0, sideOnlyRows.length + movedRows.length - rows.length);
const sideName = side === 'desktop' ? 'desktop' : 'phone';
@@ -177,6 +182,8 @@ export function SyncConflictDetails({
maxRows?: number;
previewResolution?: DesktopSyncConflictResolution | null;
}) {
const styles = useStyles();
const colors = useColors();
const desktop = syncPlaylistToSnapshot(conflict.remote);
const phone = syncPlaylistToSnapshot(conflict.local);
const isNormal = desktop.kind === 'normal' && phone.kind === 'normal';
@@ -266,7 +273,7 @@ export function SyncConflictDetails({
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
container: {
gap: spacing.sm,
},
@@ -344,4 +351,4 @@ const styles = StyleSheet.create({
padding: spacing.sm,
gap: spacing.xs,
},
});
}));
+7 -4
View File
@@ -11,7 +11,8 @@ import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, usePathname } from 'expo-router';
import { Text } from '@/components/Text';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatRelativeTime } from '@/lib/format';
import {
buildSyncConflictResolutionPreview,
@@ -62,6 +63,8 @@ function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): s
}
export function SyncConflictPrompt() {
const styles = useStyles();
const colors = useColors();
const conflicts = useDesktopSyncStore((s) => s.conflicts);
const status = useDesktopSyncStore((s) => s.status);
const promptVisible = useDesktopSyncStore((s) => s.conflictPromptVisible);
@@ -209,10 +212,10 @@ export function SyncConflictPrompt() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.55)',
backgroundColor: colors.backdrop,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
@@ -311,4 +314,4 @@ const styles = StyleSheet.create({
disabled: {
opacity: 0.5,
},
});
}));
+130
View File
@@ -0,0 +1,130 @@
import { Appearance } from 'react-native';
import { create } from 'zustand';
import { AstraSystemColors, type SystemPalette } from '../../modules/astra-system-colors';
import { openLibraryDb } from '@/db/database';
import { getSetting, setSetting } from '@/db/queries';
import { parseAccentId, DEFAULT_ACCENT, type AccentId } from '@/theme/accents';
import {
parseBaseTheme,
parsePreferredDark,
resolveTheme,
type AppTheme,
type BaseThemeId,
type PreferredDark,
} from '@/theme/resolve';
/**
* Theme preferences + the resolved palette. SQLite (settings table) is the
* source of truth, mirrored in memory (same shape as settingsStore). Every
* change recomputes `theme` exactly once one new object identity per switch
* is what invalidates the per-palette style caches in `createThemedStyles`.
*/
const BASE_THEME_KEY = 'theme_base';
const PREFERRED_DARK_KEY = 'theme_preferred_dark';
const ACCENT_KEY = 'theme_accent';
type SystemScheme = 'light' | 'dark';
function currentSystemScheme(): SystemScheme {
return Appearance.getColorScheme() === 'light' ? 'light' : 'dark';
}
// Monet ramps are an input to resolution, not reactive state — nothing renders
// them directly, they only matter through the recomputed `theme`.
let materialYouRamps: SystemPalette | null = null;
interface ResolutionInputs {
baseTheme: BaseThemeId;
preferredDark: PreferredDark;
accentId: AccentId;
systemScheme: SystemScheme;
}
function recompute(inputs: ResolutionInputs): AppTheme {
return resolveTheme({ ...inputs, materialYouRamps });
}
interface ThemeStore extends ResolutionInputs {
materialYouAvailable: boolean;
theme: AppTheme;
loaded: boolean;
load: () => Promise<void>;
setBaseTheme: (id: BaseThemeId) => Promise<void>;
setPreferredDark: (id: PreferredDark) => Promise<void>;
setAccent: (id: AccentId) => Promise<void>;
/** Re-reads OS scheme + monet ramps; no-op set when nothing changed. */
refreshSystemInputs: () => void;
}
const DEFAULT_INPUTS: ResolutionInputs = {
baseTheme: 'midnight',
preferredDark: 'midnight',
accentId: DEFAULT_ACCENT,
systemScheme: currentSystemScheme(),
};
export const useThemeStore = create<ThemeStore>((set, get) => ({
...DEFAULT_INPUTS,
materialYouAvailable: AstraSystemColors.isAvailable(),
theme: recompute(DEFAULT_INPUTS),
loaded: false,
load: async () => {
if (get().loaded) return;
const db = await openLibraryDb();
const [base, dark, accent] = await Promise.all([
getSetting(db, BASE_THEME_KEY),
getSetting(db, PREFERRED_DARK_KEY),
getSetting(db, ACCENT_KEY),
]);
if (get().materialYouAvailable) {
materialYouRamps = AstraSystemColors.getSystemPalette();
}
const inputs: ResolutionInputs = {
baseTheme: parseBaseTheme(base),
preferredDark: parsePreferredDark(dark),
accentId: parseAccentId(accent),
systemScheme: currentSystemScheme(),
};
set({ ...inputs, theme: recompute(inputs), loaded: true });
},
setBaseTheme: async (id) => {
if (get().baseTheme === id) return;
const inputs: ResolutionInputs = { ...get(), baseTheme: id };
set({ baseTheme: id, theme: recompute(inputs) });
const db = await openLibraryDb();
await setSetting(db, BASE_THEME_KEY, id);
},
setPreferredDark: async (id) => {
if (get().preferredDark === id) return;
const inputs: ResolutionInputs = { ...get(), preferredDark: id };
set({ preferredDark: id, theme: recompute(inputs) });
const db = await openLibraryDb();
await setSetting(db, PREFERRED_DARK_KEY, id);
},
setAccent: async (id) => {
if (get().accentId === id) return;
const inputs: ResolutionInputs = { ...get(), accentId: id };
set({ accentId: id, theme: recompute(inputs) });
const db = await openLibraryDb();
await setSetting(db, ACCENT_KEY, id);
},
refreshSystemInputs: () => {
const scheme = currentSystemScheme();
const ramps = get().materialYouAvailable ? AstraSystemColors.getSystemPalette() : null;
const rampsChanged = JSON.stringify(ramps) !== JSON.stringify(materialYouRamps);
if (scheme === get().systemScheme && !rampsChanged) return;
materialYouRamps = ramps;
const inputs: ResolutionInputs = { ...get(), systemScheme: scheme };
set({ systemScheme: scheme, theme: recompute(inputs) });
},
}));
// OS dark/light toggles while the app runs. Wallpaper (monet) changes can't
// happen while Astra is foregrounded — those are covered by the AppState
// 'active' refresh wired in _layout.tsx.
Appearance.addChangeListener(() => useThemeStore.getState().refreshSystemInputs());
+77
View File
@@ -0,0 +1,77 @@
import { hexToHsl, hslToHex, rgbaFromHex } from './colorUtils';
/**
* Named accent choices. Each is a single base hex; the full 5-token ramp is
* derived in `deriveAccent`. Base hexes are picked at similar perceived
* brightness so the derived ramps land consistently. `overrides` is the
* escape hatch if a hue derives badly (HSL lightness is perceptually off for
* yellows amber is the likely candidate).
*/
export interface AccentDef {
label: string;
base: string;
overridesDark?: Partial<AccentTokens>;
overridesLight?: Partial<AccentTokens>;
}
export const ACCENTS = {
// The original default. Derivation lands within ±2/255 of the hand-tuned
// ramp; overrides pin the legacy values exactly so Midnight is unchanged.
indigo: {
label: 'Indigo',
base: '#5b8aff',
overridesDark: {
accentHover: '#82a6ff',
accentText: '#a9c0ff',
accentTextStrong: '#d6e2ff',
},
},
cyan: { label: 'Astra Cyan', base: '#00b3ff' }, // logoMain
violet: { label: 'Violet', base: '#9d7bff' },
magenta: { label: 'Magenta', base: '#ff6b9d' },
emerald: { label: 'Emerald', base: '#2dd4a0' },
amber: { label: 'Amber', base: '#ffb454' },
crimson: { label: 'Crimson', base: '#ff5c5c' },
} as const satisfies Record<string, AccentDef>;
export type AccentId = keyof typeof ACCENTS;
export const DEFAULT_ACCENT: AccentId = 'indigo';
export const ACCENT_IDS = Object.keys(ACCENTS) as AccentId[];
export function parseAccentId(value: string | null): AccentId {
return value !== null && value in ACCENTS ? (value as AccentId) : DEFAULT_ACCENT;
}
export interface AccentTokens {
accent: string;
accentHover: string;
accentGlow: string;
accentText: string;
accentTextStrong: string;
}
/**
* Reproduces the hand-tuned indigo ramp (base hsl(223,100%,68%) hover L+8,
* text L=83, textStrong L=92, glow rgba(base, .30)). Light themes ramp DOWN
* (text darker than base) because accentText sits on light surfaces.
*/
export function deriveAccent(id: AccentId, isDark: boolean): AccentTokens {
const def: AccentDef = ACCENTS[id];
const { h, s, l } = hexToHsl(def.base);
const derived: AccentTokens = isDark
? {
accent: def.base,
accentHover: hslToHex(h, s, Math.min(l + 8, 96)),
accentGlow: rgbaFromHex(def.base, 0.3),
accentText: hslToHex(h, s, 83),
accentTextStrong: hslToHex(h, s, 92),
}
: {
accent: def.base,
accentHover: hslToHex(h, s, Math.max(l - 8, 20)),
accentGlow: rgbaFromHex(def.base, 0.25),
accentText: hslToHex(h, s, 36),
accentTextStrong: hslToHex(h, s, 26),
};
return { ...derived, ...(isDark ? def.overridesDark : def.overridesLight) };
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Tiny color math helpers for palette derivation. No dependencies.
* All hex I/O is 6-digit `#rrggbb` (the solid-token invariant see palettes.ts).
*/
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16),
};
}
function channelToHex(value: number): string {
return Math.round(Math.min(255, Math.max(0, value)))
.toString(16)
.padStart(2, '0');
}
export function rgbToHex(r: number, g: number, b: number): string {
return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}`;
}
/** h in degrees [0,360), s/l in percent [0,100]. */
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRgb(hex);
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: l * 100 };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return { h: h * 360, s: s * 100, l: l * 100 };
}
export function hslToHex(h: number, s: number, l: number): string {
const sn = Math.min(100, Math.max(0, s)) / 100;
const ln = Math.min(100, Math.max(0, l)) / 100;
const hn = (((h % 360) + 360) % 360) / 360;
if (sn === 0) {
const v = ln * 255;
return rgbToHex(v, v, v);
}
const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;
const p = 2 * ln - q;
const hue = (t: number) => {
let tn = t;
if (tn < 0) tn += 1;
if (tn > 1) tn -= 1;
if (tn < 1 / 6) return p + (q - p) * 6 * tn;
if (tn < 1 / 2) return q;
if (tn < 2 / 3) return p + (q - p) * (2 / 3 - tn) * 6;
return p;
};
return rgbToHex(hue(hn + 1 / 3) * 255, hue(hn) * 255, hue(hn - 1 / 3) * 255);
}
export function rgbaFromHex(hex: string, alpha: number): string {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** Linear RGB mix of two hex colors: t=0 → a, t=1 → b. */
export function mixHex(a: string, b: string, t: number): string {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
return rgbToHex(
ca.r + (cb.r - ca.r) * t,
ca.g + (cb.g - ca.g) * t,
ca.b + (cb.b - ca.b) * t,
);
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Astra color tokens. Dark-only. M3 redesign shifted the palette from
* cyan-on-black toward a softer indigo-on-navy "mobile-first" language; these
* tokens are the single source of truth, so a future theming pass can swap them.
*/
export const colors = {
// Base backgrounds (navy)
bgPrimary: '#080a0f',
bgSecondary: '#0c0f18',
bgTertiary: '#11162a',
// Glass / surface overlays (subtle blue-tinted alphas)
glassBg: 'rgba(124, 146, 196, 0.05)',
glassBorder: 'rgba(124, 146, 196, 0.16)',
glassHighlight: 'rgba(140, 162, 208, 0.08)',
// Text (blue-tinted neutrals)
textPrimary: '#e2e8f4',
textSecondary: '#8a98b8',
textTertiary: '#52607f',
// Warning amber (desktop .graph-meta-chip-warning)
warning: '#f3d27d',
// Indigo accent
accent: '#5b8aff',
accentHover: '#82a6ff',
accentGlow: 'rgba(91, 138, 255, 0.3)',
accentText: '#a9c0ff',
accentTextStrong: '#d6e2ff',
// Astra mark fills (hsl(198 …) from the desktop logo)
logoMain: '#00b3ff', // hsl(198 100% 50%)
logoShadow: '#152932', // hsl(198 40% 14%)
logoBackdrop: '#05070a',
} as const;
export type ColorToken = keyof typeof colors;
+4 -3
View File
@@ -1,9 +1,10 @@
import { colors } from './colors';
import { fonts, fontSize, lineHeight } from './typography';
import { spacing, radius, layout, durations } from './spacing';
// Colors are theme-resolved at runtime now — consume them via
// `useColors()` / `createThemedStyles()` from '@/theme/themed'.
// Palette types + base palettes live in '@/theme/palettes'.
export const theme = {
colors,
fonts,
fontSize,
lineHeight,
@@ -15,4 +16,4 @@ export const theme = {
export type Theme = typeof theme;
export { colors, fonts, fontSize, lineHeight, spacing, radius, layout, durations };
export { fonts, fontSize, lineHeight, spacing, radius, layout, durations };
+164
View File
@@ -0,0 +1,164 @@
/**
* Astra palettes. Every theme resolves to a `Palette` same token names the
* old dark-only `colors` object had, so consumers are theme-agnostic.
*
* INVARIANT: solid-color tokens must stay 6-digit `#rrggbb` hex the Skia
* visualizers (SpectrumCurve, OscilloscopeWave, EQGraph, GraphicResponseCurve)
* slice hex chars to build alpha variants. Only `glass*`, `*Glow`,
* `overlayFaint`, and `backdrop` may be `rgba()` strings (they are never fed
* through those helpers).
*/
export interface Palette {
// Base backgrounds
bgPrimary: string;
bgSecondary: string;
bgTertiary: string;
// Glass / surface overlays
glassBg: string;
glassBorder: string;
glassHighlight: string;
// Text ramp
textPrimary: string;
textSecondary: string;
textTertiary: string;
warning: string;
// Accent ramp (derived per accent choice, or from Material You)
accent: string;
accentHover: string;
accentGlow: string;
accentText: string;
accentTextStrong: string;
// Astra mark fills
logoMain: string;
logoShadow: string;
logoBackdrop: string;
/** Faint bg-tinted wash (mini-player artwork overlay, alphabet rail, home overlay). */
overlayFaint: string;
/** Modal/sheet scrim behind ActionSheet, prompts, target picker. */
backdrop: string;
}
export type ColorToken = keyof Palette;
/** A base theme without the accent ramp — accents merge in at resolve time. */
export type BasePalette = Omit<
Palette,
'accent' | 'accentHover' | 'accentGlow' | 'accentText' | 'accentTextStrong'
>;
/**
* Midnight the original Astra dark. M3 redesign shifted the palette from
* cyan-on-black toward a softer indigo-on-navy "mobile-first" language.
*/
export const midnightBase: BasePalette = {
bgPrimary: '#080a0f',
bgSecondary: '#0c0f18',
bgTertiary: '#11162a',
glassBg: 'rgba(124, 146, 196, 0.05)',
glassBorder: 'rgba(124, 146, 196, 0.16)',
glassHighlight: 'rgba(140, 162, 208, 0.08)',
textPrimary: '#e2e8f4',
textSecondary: '#8a98b8',
textTertiary: '#52607f',
warning: '#f3d27d',
logoMain: '#00b3ff', // hsl(198 100% 50%)
logoShadow: '#152932', // hsl(198 40% 14%)
logoBackdrop: '#05070a',
overlayFaint: 'rgba(8, 10, 15, 0.24)',
backdrop: 'rgba(0, 0, 0, 0.55)',
};
/**
* Dark neutral gray dark, no navy cast. Matches the tone zone Material You
* produces on a neutral wallpaper (which is where these values came from).
*/
export const darkBase: BasePalette = {
bgPrimary: '#0f0f12',
bgSecondary: '#141519',
bgTertiary: '#1e2026',
glassBg: 'rgba(165, 175, 195, 0.05)',
glassBorder: 'rgba(165, 175, 195, 0.16)',
glassHighlight: 'rgba(175, 185, 205, 0.08)',
textPrimary: '#e6e8ec',
textSecondary: '#979da8',
textTertiary: '#5b616c',
warning: '#f3d27d',
logoMain: '#00b3ff',
logoShadow: '#152932',
logoBackdrop: '#0a0a0c',
overlayFaint: 'rgba(15, 15, 18, 0.24)',
backdrop: 'rgba(0, 0, 0, 0.55)',
};
/**
* AMOLED true black base, faint navy kept on raised surfaces so cards still
* read as surfaces. Glass borders slightly stronger: they carry the structure
* that background contrast provides on Midnight.
*/
export const amoledBase: BasePalette = {
bgPrimary: '#000000',
bgSecondary: '#05060a',
bgTertiary: '#0b0e1a',
glassBg: 'rgba(124, 146, 196, 0.06)',
glassBorder: 'rgba(124, 146, 196, 0.20)',
glassHighlight: 'rgba(140, 162, 208, 0.10)',
textPrimary: '#e2e8f4',
textSecondary: '#8a98b8',
textTertiary: '#52607f',
warning: '#f3d27d',
logoMain: '#00b3ff',
logoShadow: '#101f26',
logoBackdrop: '#000000',
overlayFaint: 'rgba(0, 0, 0, 0.30)',
backdrop: 'rgba(0, 0, 0, 0.62)',
};
/**
* Light same navy hue family (H222) inverted: cool near-white surfaces,
* navy-ink text ramp, glass flips to dark ink at low alpha. Starting values;
* expect an on-device tuning pass (glass alphas, EQ fills, glow visibility).
*/
export const lightBase: BasePalette = {
bgPrimary: '#f4f6fb',
bgSecondary: '#eaeef7',
bgTertiary: '#dde4f2',
glassBg: 'rgba(52, 74, 130, 0.06)',
glassBorder: 'rgba(52, 74, 130, 0.18)',
glassHighlight: 'rgba(52, 74, 130, 0.10)',
textPrimary: '#171d2e',
textSecondary: '#4d5a78',
textTertiary: '#8a94ad',
warning: '#9a7b1f',
logoMain: '#0087cc',
logoShadow: '#c9dbe4',
logoBackdrop: '#eef2f8',
overlayFaint: 'rgba(244, 246, 251, 0.30)',
backdrop: 'rgba(23, 29, 46, 0.40)',
};
+165
View File
@@ -0,0 +1,165 @@
import type { SystemPalette } from '../../modules/astra-system-colors';
import { deriveAccent, type AccentId } from './accents';
import { mixHex, rgbaFromHex } from './colorUtils';
import {
amoledBase,
darkBase,
lightBase,
midnightBase,
type BasePalette,
type Palette,
} from './palettes';
/** What the user picks in settings. */
export type BaseThemeId = 'system' | 'midnight' | 'dark' | 'amoled' | 'light' | 'materialYou';
/** Which dark theme "System" resolves to when the OS is dark. */
export type PreferredDark = 'midnight' | 'dark' | 'amoled';
/** What actually renders after resolution. */
export type ResolvedThemeId =
| 'midnight'
| 'dark'
| 'amoled'
| 'light'
| 'materialYouDark'
| 'materialYouLight';
export interface AppTheme {
id: ResolvedThemeId;
isDark: boolean;
statusBarStyle: 'light' | 'dark';
colors: Palette;
}
export function parseBaseTheme(value: string | null): BaseThemeId {
switch (value) {
case 'system':
case 'midnight':
case 'dark':
case 'amoled':
case 'light':
case 'materialYou':
return value;
default:
return 'midnight';
}
}
export function parsePreferredDark(value: string | null): PreferredDark {
return value === 'amoled' || value === 'dark' ? value : 'midnight';
}
const BASES: Record<
'midnight' | 'dark' | 'amoled' | 'light',
{ base: BasePalette; isDark: boolean }
> = {
midnight: { base: midnightBase, isDark: true },
dark: { base: darkBase, isDark: true },
amoled: { base: amoledBase, isDark: true },
light: { base: lightBase, isDark: false },
};
/** Monet ramp index for tone t ∈ {0,10,50,100,200,...,900,1000}. */
function tone(ramp: string[], t: number): string {
const index = t === 0 ? 0 : t === 10 ? 1 : t === 50 ? 2 : t / 100 + 2;
return ramp[index];
}
/**
* Maps monet ramps onto Astra tokens. Dark surfaces come from the accent2
* ramp (monet's muted wallpaper-hue ramp, built exactly for tinted surfaces)
* so the wallpaper color is actually visible neutral1 is too low-chroma and
* mixing it toward black erased the tint entirely. The neutral-gray look that
* produced is now its own static "Dark" theme. Fractions are a starting
* point; expect a device tuning pass. All solid outputs stay #rrggbb hex
* (Skia invariant see palettes.ts).
*/
export function buildMaterialYouPalette(ramps: SystemPalette, isDark: boolean): Palette {
if (isDark) {
const bgPrimary = mixHex(tone(ramps.accent2, 900), '#000000', 0.35);
return {
bgPrimary,
bgSecondary: mixHex(tone(ramps.accent2, 900), '#000000', 0.12),
bgTertiary: mixHex(tone(ramps.accent2, 800), '#000000', 0.25),
glassBg: rgbaFromHex(tone(ramps.accent2, 200), 0.06),
glassBorder: rgbaFromHex(tone(ramps.accent2, 200), 0.18),
glassHighlight: rgbaFromHex(tone(ramps.accent2, 200), 0.09),
textPrimary: tone(ramps.neutral1, 50),
textSecondary: tone(ramps.neutral2, 300),
textTertiary: tone(ramps.neutral2, 500),
warning: '#f3d27d',
accent: tone(ramps.accent1, 200),
accentHover: tone(ramps.accent1, 100),
accentGlow: rgbaFromHex(tone(ramps.accent1, 200), 0.3),
accentText: tone(ramps.accent1, 100),
accentTextStrong: tone(ramps.accent1, 50),
logoMain: '#00b3ff',
logoShadow: '#152932',
logoBackdrop: '#05070a',
overlayFaint: rgbaFromHex(bgPrimary, 0.24),
backdrop: 'rgba(0, 0, 0, 0.55)',
};
}
const bgPrimary = tone(ramps.neutral1, 10);
return {
bgPrimary,
bgSecondary: tone(ramps.accent2, 50),
bgTertiary: tone(ramps.accent2, 100),
glassBg: rgbaFromHex(tone(ramps.neutral2, 700), 0.06),
glassBorder: rgbaFromHex(tone(ramps.neutral2, 700), 0.16),
glassHighlight: rgbaFromHex(tone(ramps.neutral2, 700), 0.09),
textPrimary: tone(ramps.neutral1, 900),
textSecondary: tone(ramps.neutral2, 700),
textTertiary: tone(ramps.neutral2, 500),
warning: '#9a7b1f',
accent: tone(ramps.accent1, 600),
accentHover: tone(ramps.accent1, 500),
accentGlow: rgbaFromHex(tone(ramps.accent1, 600), 0.25),
accentText: tone(ramps.accent1, 700),
accentTextStrong: tone(ramps.accent1, 800),
logoMain: '#0087cc',
logoShadow: '#c9dbe4',
logoBackdrop: '#eef2f8',
overlayFaint: rgbaFromHex(bgPrimary, 0.24),
backdrop: rgbaFromHex(tone(ramps.neutral1, 900), 0.45),
};
}
export interface ResolveThemeInput {
baseTheme: BaseThemeId;
preferredDark: PreferredDark;
accentId: AccentId;
systemScheme: 'light' | 'dark';
/** null → Material You unavailable (iOS, <API 31, module absent). */
materialYouRamps: SystemPalette | null;
}
/** Pure resolution: settings + system inputs → one immutable AppTheme. */
export function resolveTheme(input: ResolveThemeInput): AppTheme {
const { baseTheme, preferredDark, accentId, systemScheme, materialYouRamps } = input;
if (baseTheme === 'materialYou' && materialYouRamps !== null) {
const isDark = systemScheme === 'dark';
return {
id: isDark ? 'materialYouDark' : 'materialYouLight',
isDark,
statusBarStyle: isDark ? 'light' : 'dark',
colors: buildMaterialYouPalette(materialYouRamps, isDark),
};
}
// 'system' — and 'materialYou' with no ramps — follow the OS scheme.
const staticId: 'midnight' | 'dark' | 'amoled' | 'light' =
baseTheme === 'midnight' || baseTheme === 'dark' || baseTheme === 'amoled' || baseTheme === 'light'
? baseTheme
: systemScheme === 'dark'
? preferredDark
: 'light';
const { base, isDark } = BASES[staticId];
return {
id: staticId,
isDark,
statusBarStyle: isDark ? 'light' : 'dark',
colors: { ...base, ...deriveAccent(accentId, isDark) },
};
}
+42
View File
@@ -0,0 +1,42 @@
import { StyleSheet } from 'react-native';
import { useThemeStore } from '@/stores/themeStore';
import type { Palette } from './palettes';
import type { AppTheme } from './resolve';
/** The resolved theme (id, isDark, statusBarStyle, colors). */
export function useTheme(): AppTheme {
return useThemeStore((s) => s.theme);
}
/** Just the palette — the common case for inline `colors.x` props. */
export function useColors(): Palette {
return useThemeStore((s) => s.theme.colors);
}
/**
* Module-scope factory for theme-aware styles:
*
* const useStyles = createThemedStyles((colors) => ({ ... }));
* // in the component:
* const styles = useStyles();
*
* Styles are built lazily and cached per palette OBJECT IDENTITY one
* StyleSheet build per theme per file, referentially stable across renders
* (so downstream memoization behaves exactly like the old module-scope
* constant). The WeakMap write during render is an idempotent lazy-init on an
* immutable key, which is safe under React's concurrent re-renders.
*/
export function createThemedStyles<T extends StyleSheet.NamedStyles<T>>(
factory: (colors: Palette) => T,
): () => T {
const cache = new WeakMap<Palette, T>();
return function useThemedStyles(): T {
const colors = useThemeStore((s) => s.theme.colors);
let styles = cache.get(colors);
if (styles === undefined) {
styles = StyleSheet.create(factory(colors));
cache.set(colors, styles);
}
return styles;
};
}