diff --git a/package.json b/package.json
index 92ed59e..d9a5148 100644
--- a/package.json
+++ b/package.json
@@ -84,6 +84,7 @@
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
+ "test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
"test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts",
"test:release-config": "node --experimental-strip-types --test plugins/withAstraAndroidRelease.test.mjs scripts/release/android-release.test.mjs src/release/buildInfo.test.mts",
diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx
index add3331..db8aa9d 100644
--- a/src/app/(tabs)/eq.tsx
+++ b/src/app/(tabs)/eq.tsx
@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react';
import {
- Alert,
InteractionManager,
Pressable,
StyleSheet,
@@ -20,6 +19,7 @@ import {
} from 'expo-file-system/legacy';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { EQGraph } from '@/components/eq/EQGraph';
import { BandStrip } from '@/components/eq/BandStrip';
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
@@ -171,7 +171,7 @@ export default function EQScreen() {
};
const showPresetImportError = useCallback((message = 'That file is not an Astra EQ preset.') => {
- Alert.alert('Could not import preset', message);
+ showAppDialog({ title: 'Could not import preset', message });
}, []);
const deletePreset = useCallback((preset: EQPreset) => {
@@ -188,14 +188,18 @@ export default function EQScreen() {
finishDelete();
return;
}
- Alert.alert(
- `Delete ${preset.name}?`,
- `This will also clear ${assignmentCount} device assignment${assignmentCount === 1 ? '' : 's'}. The current sound will not change.`,
- [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Delete', style: 'destructive', onPress: finishDelete },
- ]
- );
+ showAppDialog({
+ title: `Delete ${preset.name}?`,
+ message: `This will also clear ${assignmentCount} device assignment${assignmentCount === 1 ? '' : 's'}. The current sound will not change.`,
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ {
+ label: 'Delete',
+ role: 'destructive',
+ onPress: finishDelete,
+ },
+ ],
+ });
}, []);
const runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => {
@@ -211,18 +215,24 @@ export default function EQScreen() {
EQ_PRESET_MIME_TYPE
);
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
- Alert.alert('Preset exported', `Saved ${fileName}.`);
+ showAppDialog({ title: 'Preset exported', message: `Saved ${fileName}.` });
return;
}
if (action === 'share') {
const available = await Sharing.isAvailableAsync();
if (!available) {
- Alert.alert('Share unavailable', 'This device cannot open a share sheet right now.');
+ showAppDialog({
+ title: 'Share unavailable',
+ message: 'This device cannot open a share sheet right now.',
+ });
return;
}
if (!cacheDirectory) {
- Alert.alert('Share unavailable', 'Astra could not create a temporary preset file.');
+ showAppDialog({
+ title: 'Share unavailable',
+ message: 'Astra could not create a temporary preset file.',
+ });
return;
}
const fileUri = `${cacheDirectory}${buildEQPresetFileName(preset.name)}`;
@@ -238,7 +248,10 @@ export default function EQScreen() {
setQrPreset({ name: preset.name, value: encodeEQPresetQr(preset) });
setSheet('qr');
} catch {
- Alert.alert('Preset sharing failed', 'Astra could not finish that preset sharing action.');
+ showAppDialog({
+ title: 'Preset sharing failed',
+ message: 'Astra could not finish that preset sharing action.',
+ });
}
}, []);
diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx
index 6505705..e1b709e 100644
--- a/src/app/(tabs)/library/playlist/[id].tsx
+++ b/src/app/(tabs)/library/playlist/[id].tsx
@@ -6,8 +6,7 @@ import {
import {
View,
Pressable,
- StyleSheet,
- Alert
+ StyleSheet
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
@@ -24,6 +23,7 @@ import {
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
@@ -199,34 +199,38 @@ export default function PlaylistScreen() {
try {
const result = await exportM3u(target);
if (result) {
- Alert.alert(
- 'Playlist exported',
- `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`
- );
+ showAppDialog({
+ title: 'Playlist exported',
+ message: `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`,
+ });
}
} catch (err) {
- Alert.alert('Export failed', errorMessage(err));
+ showAppDialog({ title: 'Export failed', message: errorMessage(err) });
}
};
const confirmDelete = (target: Playlist) => {
- Alert.alert('Delete playlist?', `"${target.name}" will be deleted. Tracks are not touched.`, [
- { text: 'Cancel', style: 'cancel' },
- {
- text: 'Delete',
- style: 'destructive',
- onPress: () => {
- void (async () => {
- try {
- await deletePlaylist(target.id);
- goBack();
- } catch (err) {
- Alert.alert('Delete failed', errorMessage(err));
- }
- })();
+ showAppDialog({
+ title: 'Delete playlist?',
+ message: `"${target.name}" will be deleted. Tracks are not touched.`,
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ {
+ label: 'Delete',
+ role: 'destructive',
+ onPress: () => {
+ void (async () => {
+ try {
+ await deletePlaylist(target.id);
+ goBack();
+ } catch (err) {
+ showAppDialog({ title: 'Delete failed', message: errorMessage(err) });
+ }
+ })();
+ },
},
- },
- ]);
+ ],
+ });
};
// Move/remove only exist on real playlists; favorites rows use the standard
diff --git a/src/app/(tabs)/library/playlist/edit-dynamic.tsx b/src/app/(tabs)/library/playlist/edit-dynamic.tsx
index 98f512f..795f171 100644
--- a/src/app/(tabs)/library/playlist/edit-dynamic.tsx
+++ b/src/app/(tabs)/library/playlist/edit-dynamic.tsx
@@ -4,7 +4,6 @@ import {
useState,
} from 'react';
import {
- Alert,
KeyboardAvoidingView,
Platform,
Pressable,
@@ -19,6 +18,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { SegmentedControl } from '@/components/SegmentedControl';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import {
AppSheet,
AppSheetItem,
@@ -784,7 +784,10 @@ export default function DynamicPlaylistEditorScreen() {
})
.catch((err) => {
if (!didCancel) {
- Alert.alert('Rules unavailable', err instanceof Error ? err.message : String(err));
+ showAppDialog({
+ title: 'Rules unavailable',
+ message: err instanceof Error ? err.message : String(err),
+ });
router.back();
}
})
@@ -864,10 +867,14 @@ export default function DynamicPlaylistEditorScreen() {
apply();
return;
}
- Alert.alert('Replace rules?', 'This preset will replace the current filters and result order.', [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Replace', style: 'destructive', onPress: apply },
- ]);
+ showAppDialog({
+ title: 'Replace rules?',
+ message: 'This preset will replace the current filters and result order.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ { label: 'Replace', role: 'destructive', onPress: apply },
+ ],
+ });
};
const openFieldPicker = (target: 'new' | ConditionEditorTarget) => {
@@ -961,7 +968,10 @@ export default function DynamicPlaylistEditorScreen() {
const createdPlaylist = await createDynamicPlaylist(trimmedName, normalizedRules);
router.replace(`/library/playlist/${createdPlaylist.id}`);
} catch (err) {
- Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
+ showAppDialog({
+ title: 'Save failed',
+ message: err instanceof Error ? err.message : String(err),
+ });
} finally {
setIsSaving(false);
}
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index faade63..49f7c94 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -50,6 +50,7 @@ import { SessionLifecycle } from '@/session/SessionLifecycle';
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
import { Text } from '@/components/Text';
+import { AppDialogHost } from '@/components/dialogs/AppDialog';
// Anchor the root stack at the tabs so a deep link straight to a top-level route
// (the widget's `recently-played`, the notification-click redirect) builds
@@ -459,6 +460,7 @@ export default function RootLayout() {
/>
)}
+
);
diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx
index 1fb25d7..024a9eb 100644
--- a/src/app/desktop-remote.tsx
+++ b/src/app/desktop-remote.tsx
@@ -5,7 +5,6 @@ import {
} from 'react';
import {
ActivityIndicator,
- Alert,
KeyboardAvoidingView,
Platform,
Pressable,
@@ -23,6 +22,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import {
radius,
spacing,
@@ -339,10 +339,14 @@ export default function DesktopRemoteScreen() {
}, [connection, discoveryAvailable, discoveryRunning, message]);
const confirmForget = () => {
- Alert.alert('Forget desktop?', 'This removes the saved desktop pairing from this phone.', [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Forget', style: 'destructive', onPress: () => void forget() },
- ]);
+ showAppDialog({
+ title: 'Forget desktop?',
+ message: 'This removes the saved desktop pairing from this phone.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ { label: 'Forget', role: 'destructive', onPress: () => void forget() },
+ ],
+ });
};
const pairDiscovered = (desktop: DesktopRemoteDiscoveredDesktop) => {
diff --git a/src/app/lastfm/edit.tsx b/src/app/lastfm/edit.tsx
index 761a283..9689292 100644
--- a/src/app/lastfm/edit.tsx
+++ b/src/app/lastfm/edit.tsx
@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
- Alert,
KeyboardAvoidingView,
Platform,
Pressable,
@@ -14,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import {
radius,
spacing,
@@ -176,21 +176,21 @@ export default function LastFmEditScreen() {
const onRemove = () => {
if (!editing) return;
- Alert.alert(
- `Remove ${editing.name}?`,
- 'This deletes the scrobble destination and its queued scrobbles from this device. Your history on the service is unaffected.',
- [
- { text: 'Cancel', style: 'cancel' },
+ showAppDialog({
+ title: `Remove ${editing.name}?`,
+ message: 'This deletes the scrobble destination and its queued scrobbles from this device. Your history on the service is unaffected.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
{
- text: 'Remove',
- style: 'destructive',
+ label: 'Remove',
+ role: 'destructive',
onPress: () => {
void deleteCustomProfile(editing.id);
router.back();
},
},
- ]
- );
+ ],
+ });
};
const backLabel = step === 'details' && !editing ? 'Service' : 'Scrobbling';
diff --git a/src/app/lastfm/index.tsx b/src/app/lastfm/index.tsx
index 4a28e83..4073c9c 100644
--- a/src/app/lastfm/index.tsx
+++ b/src/app/lastfm/index.tsx
@@ -1,6 +1,5 @@
import { useEffect } from 'react';
import {
- Alert,
Pressable,
ScrollView,
StyleSheet,
@@ -11,6 +10,7 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { HapticSwitch } from '@/components/HapticSwitch';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import {
radius,
spacing,
@@ -66,24 +66,28 @@ export default function LastFmScreen() {
const connectOfficial = (profile: LastFmProfileStatus) => {
if (status && !status.hasApiCredentials) {
- Alert.alert(
- 'Last.fm not configured',
- 'This build has no Last.fm API key. Set EXPO_PUBLIC_LASTFM_API_KEY / _SHARED_SECRET, or add a custom Last.fm-compatible / ListenBrainz destination instead.'
- );
+ showAppDialog({
+ title: 'Last.fm not configured',
+ message: 'This build has no Last.fm API key. Set EXPO_PUBLIC_LASTFM_API_KEY / _SHARED_SECRET, or add a custom Last.fm-compatible / ListenBrainz destination instead.',
+ });
return;
}
void beginAuth(profile.id);
};
const confirmDisconnect = (profile: LastFmProfileStatus) => {
- Alert.alert(`Disconnect ${profile.name}?`, 'Astra will stop scrobbling to this destination.', [
- { text: 'Cancel', style: 'cancel' },
- {
- text: 'Disconnect',
- style: 'destructive',
- onPress: () => void disconnectProfile(profile.id),
- },
- ]);
+ showAppDialog({
+ title: `Disconnect ${profile.name}?`,
+ message: 'Astra will stop scrobbling to this destination.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ {
+ label: 'Disconnect',
+ role: 'destructive',
+ onPress: () => void disconnectProfile(profile.id),
+ },
+ ],
+ });
};
const renderOfficial = (profile: LastFmProfileStatus) => {
diff --git a/src/app/settings/info.tsx b/src/app/settings/info.tsx
index 6512d8a..4eb0e52 100644
--- a/src/app/settings/info.tsx
+++ b/src/app/settings/info.tsx
@@ -1,5 +1,4 @@
import {
- Alert,
Linking,
View,
} from 'react-native';
@@ -11,6 +10,7 @@ import {
SettingsSectionScreen,
} from '@/components/settings/SettingsSectionScaffold';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { createBuildInfo } from '@/release/buildInfo';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
@@ -26,7 +26,10 @@ async function openExternalLink(url: string, label: string) {
try {
await Linking.openURL(url);
} catch {
- Alert.alert('Unable to open link', `Astra could not open ${label}.`);
+ showAppDialog({
+ title: 'Unable to open link',
+ message: `Astra could not open ${label}.`,
+ });
}
}
diff --git a/src/app/settings/troubleshooting.tsx b/src/app/settings/troubleshooting.tsx
index 58a8561..60c6dff 100644
--- a/src/app/settings/troubleshooting.tsx
+++ b/src/app/settings/troubleshooting.tsx
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
-import { ActivityIndicator, Alert, Pressable, View } from 'react-native';
+import { ActivityIndicator, Pressable, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { ScanProgress } from '@/components/library/ScanProgress';
import {
SettingsNavRow,
@@ -80,35 +81,35 @@ export default function TroubleshootingSettingsScreen() {
};
const confirmRebuild = () => {
- Alert.alert(
- 'Rebuild local library index?',
- 'A foreground scan will re-read every local track. Folders, playlists, favorites, history, remote sources, and settings are preserved.',
- [
- { text: 'Cancel', style: 'cancel' },
+ showAppDialog({
+ title: 'Rebuild local library index?',
+ message: 'A foreground scan will re-read every local track. Folders, playlists, favorites, history, remote sources, and settings are preserved.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
{
- text: 'Rebuild',
+ label: 'Rebuild',
onPress: () => void run(
'rebuild',
() => useLibraryStore.getState().rebuildLocalIndex(),
'Local library index rebuilt.',
),
},
- ]
- );
+ ],
+ });
};
const confirmOnboarding = () => {
- Alert.alert(
- 'Replay onboarding?',
- 'The first-run setup opens immediately. Your library and settings are kept.',
- [
- { text: 'Cancel', style: 'cancel' },
+ showAppDialog({
+ title: 'Replay onboarding?',
+ message: 'The first-run setup opens immediately. Your library and settings are kept.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
{
- text: 'Replay',
+ label: 'Replay',
onPress: () => void run('onboarding', () => useOnboardingStore.getState().reset(), 'Opening onboarding…'),
},
- ]
- );
+ ],
+ });
};
return (
diff --git a/src/app/sources/index.tsx b/src/app/sources/index.tsx
index 267e620..a533c95 100644
--- a/src/app/sources/index.tsx
+++ b/src/app/sources/index.tsx
@@ -1,6 +1,5 @@
import { useState } from 'react';
import {
- Alert,
Pressable,
ScrollView,
StyleSheet,
@@ -10,6 +9,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import {
radius,
@@ -52,18 +52,18 @@ export default function SourcesScreen() {
const [actionFor, setActionFor] = useState(null);
const confirmRemove = (source: RemoteSourceRow) => {
- Alert.alert(
- `Remove ${source.name}?`,
- 'This removes the server and all of its tracks from your library. Favorites and playlist entries are kept but will show as missing.',
- [
- { text: 'Cancel', style: 'cancel' },
+ showAppDialog({
+ title: `Remove ${source.name}?`,
+ message: 'This removes the server and all of its tracks from your library. Favorites and playlist entries are kept but will show as missing.',
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
{
- text: 'Remove',
- style: 'destructive',
+ label: 'Remove',
+ role: 'destructive',
onPress: () => void deleteSource(source.id, true),
},
- ]
- );
+ ],
+ });
};
const actionItems: ActionSheetItem[] = actionFor
diff --git a/src/components/dialogs/AppDialog.tsx b/src/components/dialogs/AppDialog.tsx
new file mode 100644
index 0000000..635fca4
--- /dev/null
+++ b/src/components/dialogs/AppDialog.tsx
@@ -0,0 +1,198 @@
+import { useSyncExternalStore } from 'react';
+import {
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ View,
+} from 'react-native';
+import { Text } from '@/components/Text';
+import { radius, spacing } from '@/theme';
+import { createThemedStyles, useColors } from '@/theme/themed';
+import { useRipple } from '@/theme/ripple';
+import {
+ dismissActiveDialog,
+ EMPTY_DIALOG_QUEUE,
+ enqueueDialog,
+ normalizeDialog,
+ takeActiveDialogAction,
+ type AppDialogOptions,
+ type AppDialogQueueState,
+} from './dialogQueue';
+
+let nextDialogId = 1;
+let dialogQueue: AppDialogQueueState = EMPTY_DIALOG_QUEUE;
+const listeners = new Set<() => void>();
+
+function emitChange() {
+ listeners.forEach((listener) => listener());
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot() {
+ return dialogQueue;
+}
+
+function closeDialog(expectedId: number): boolean {
+ const result = dismissActiveDialog(dialogQueue, expectedId);
+ if (!result.dismissed) return false;
+ dialogQueue = result.state;
+ emitChange();
+ return true;
+}
+
+function chooseDialogAction(expectedId: number, actionIndex: number) {
+ const result = takeActiveDialogAction(dialogQueue, expectedId, actionIndex);
+ if (!result.action) return null;
+ dialogQueue = result.state;
+ emitChange();
+ return result.action;
+}
+
+export function showAppDialog(options: AppDialogOptions): void {
+ const dialog = normalizeDialog(options, nextDialogId);
+ nextDialogId += 1;
+ dialogQueue = enqueueDialog(dialogQueue, dialog);
+ emitChange();
+}
+
+export function AppDialogHost() {
+ const queue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+ const active = queue.active;
+ const styles = useStyles();
+ const colors = useColors();
+ const ripple = useRipple();
+
+ if (!active) return null;
+
+ const selectAction = (actionIndex: number) => {
+ const action = chooseDialogAction(active.id, actionIndex);
+ action?.onPress?.();
+ };
+
+ const dismiss = () => {
+ closeDialog(active.id);
+ };
+
+ return (
+
+
+
+
+
+ {active.title}
+
+ {active.message ? (
+
+ {active.message}
+
+ ) : null}
+
+
+ {active.actions.map((action, index) => {
+ const destructive = action.role === 'destructive';
+ const secondary = action.role === 'cancel';
+ const color = destructive
+ ? colors.warning
+ : secondary
+ ? colors.textSecondary
+ : colors.accent;
+ return (
+ [
+ styles.action,
+ pressed ? styles.actionPressed : null,
+ ]}
+ onPress={() => selectAction(index)}
+ accessibilityRole="button"
+ accessibilityLabel={action.label}
+ >
+
+ {action.label}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export type {
+ AppDialogAction,
+ AppDialogActionRole,
+ AppDialogOptions,
+} from './dialogQueue';
+
+const useStyles = createThemedStyles((colors) => ({
+ backdrop: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.backdrop,
+ padding: spacing.xl,
+ },
+ card: {
+ width: '100%',
+ maxWidth: 400,
+ maxHeight: '80%',
+ backgroundColor: colors.bgSecondary,
+ borderRadius: radius.lg,
+ borderColor: colors.glassBorder,
+ borderWidth: StyleSheet.hairlineWidth,
+ padding: spacing.lg,
+ gap: spacing.md,
+ },
+ content: {
+ gap: spacing.md,
+ },
+ scroll: {
+ flexShrink: 1,
+ },
+ message: {
+ lineHeight: 21,
+ },
+ actions: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'flex-end',
+ gap: spacing.sm,
+ },
+ action: {
+ minWidth: 72,
+ minHeight: 44,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.md,
+ borderRadius: radius.md,
+ overflow: 'hidden',
+ },
+ actionPressed: {
+ backgroundColor: colors.glassHighlight,
+ },
+}));
diff --git a/src/components/dialogs/dialogQueue.test.mts b/src/components/dialogs/dialogQueue.test.mts
new file mode 100644
index 0000000..9c749fa
--- /dev/null
+++ b/src/components/dialogs/dialogQueue.test.mts
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ dismissActiveDialog,
+ EMPTY_DIALOG_QUEUE,
+ enqueueDialog,
+ normalizeDialog,
+ takeActiveDialogAction,
+} from './dialogQueue.ts';
+
+test('normalizes a notice with a default OK action', () => {
+ const dialog = normalizeDialog({ title: 'Saved' }, 1);
+ assert.deepEqual(dialog.actions, [{ label: 'OK', role: 'default' }]);
+});
+
+test('orders cancel before primary and destructive actions', () => {
+ const dialog = normalizeDialog({
+ title: 'Delete?',
+ actions: [
+ { label: 'Delete', role: 'destructive' },
+ { label: 'Cancel', role: 'cancel' },
+ ],
+ }, 1);
+ assert.deepEqual(
+ dialog.actions.map(({ label, role }) => ({ label, role })),
+ [
+ { label: 'Cancel', role: 'cancel' },
+ { label: 'Delete', role: 'destructive' },
+ ],
+ );
+});
+
+test('queues dialogs FIFO and promotes the next dialog on dismissal', () => {
+ const first = normalizeDialog({ title: 'First' }, 1);
+ const second = normalizeDialog({ title: 'Second' }, 2);
+ const third = normalizeDialog({ title: 'Third' }, 3);
+ const queued = enqueueDialog(enqueueDialog(enqueueDialog(
+ EMPTY_DIALOG_QUEUE,
+ first,
+ ), second), third);
+
+ const afterFirst = dismissActiveDialog(queued, first.id);
+ assert.equal(afterFirst.dismissed, true);
+ assert.equal(afterFirst.state.active?.title, 'Second');
+ assert.deepEqual(afterFirst.state.pending.map((dialog) => dialog.title), ['Third']);
+});
+
+test('a stale or repeated dismissal cannot consume another dialog', () => {
+ const first = normalizeDialog({ title: 'First' }, 1);
+ const second = normalizeDialog({ title: 'Second' }, 2);
+ const queued = enqueueDialog(enqueueDialog(EMPTY_DIALOG_QUEUE, first), second);
+
+ const afterFirst = dismissActiveDialog(queued, first.id);
+ const repeated = dismissActiveDialog(afterFirst.state, first.id);
+ assert.equal(repeated.dismissed, false);
+ assert.strictEqual(repeated.state, afterFirst.state);
+ assert.equal(repeated.state.active?.title, 'Second');
+});
+
+test('an action can be taken only once and a dismissal never invokes it', () => {
+ let calls = 0;
+ const dialog = normalizeDialog({
+ title: 'Delete?',
+ actions: [{
+ label: 'Delete',
+ role: 'destructive',
+ onPress: () => {
+ calls += 1;
+ },
+ }],
+ }, 1);
+ const queued = enqueueDialog(EMPTY_DIALOG_QUEUE, dialog);
+
+ const dismissed = dismissActiveDialog(queued, dialog.id);
+ assert.equal(calls, 0);
+ assert.equal(dismissed.state.active, null);
+
+ const selected = takeActiveDialogAction(queued, dialog.id, 0);
+ selected.action?.onPress?.();
+ const repeated = takeActiveDialogAction(selected.state, dialog.id, 0);
+ repeated.action?.onPress?.();
+ assert.equal(calls, 1);
+ assert.equal(repeated.action, null);
+});
diff --git a/src/components/dialogs/dialogQueue.ts b/src/components/dialogs/dialogQueue.ts
new file mode 100644
index 0000000..9b3957d
--- /dev/null
+++ b/src/components/dialogs/dialogQueue.ts
@@ -0,0 +1,90 @@
+export type AppDialogActionRole = 'default' | 'cancel' | 'destructive';
+
+export interface AppDialogAction {
+ label: string;
+ role?: AppDialogActionRole;
+ onPress?: () => void;
+}
+
+export interface AppDialogOptions {
+ title: string;
+ message?: string;
+ actions?: readonly AppDialogAction[];
+}
+
+export interface AppDialog {
+ id: number;
+ title: string;
+ message?: string;
+ actions: readonly AppDialogAction[];
+}
+
+export interface AppDialogQueueState {
+ active: AppDialog | null;
+ pending: readonly AppDialog[];
+}
+
+export const EMPTY_DIALOG_QUEUE: AppDialogQueueState = {
+ active: null,
+ pending: [],
+};
+
+export function normalizeDialog(options: AppDialogOptions, id: number): AppDialog {
+ const sourceActions = options.actions?.length
+ ? options.actions
+ : [{ label: 'OK', role: 'default' as const }];
+ const actions = [
+ ...sourceActions.filter((action) => action.role === 'cancel'),
+ ...sourceActions.filter((action) => action.role !== 'cancel'),
+ ].map((action) => ({
+ ...action,
+ role: action.role ?? 'default',
+ }));
+
+ return {
+ id,
+ title: options.title,
+ message: options.message,
+ actions,
+ };
+}
+
+export function enqueueDialog(
+ state: AppDialogQueueState,
+ dialog: AppDialog,
+): AppDialogQueueState {
+ if (!state.active) {
+ return { active: dialog, pending: state.pending };
+ }
+ return { active: state.active, pending: [...state.pending, dialog] };
+}
+
+export function dismissActiveDialog(
+ state: AppDialogQueueState,
+ expectedId: number,
+): { state: AppDialogQueueState; dismissed: boolean } {
+ if (state.active?.id !== expectedId) {
+ return { state, dismissed: false };
+ }
+ const [next = null, ...pending] = state.pending;
+ return {
+ state: { active: next, pending },
+ dismissed: true,
+ };
+}
+
+export function takeActiveDialogAction(
+ state: AppDialogQueueState,
+ expectedId: number,
+ actionIndex: number,
+): { state: AppDialogQueueState; action: AppDialogAction | null } {
+ const action = state.active?.id === expectedId
+ ? state.active.actions[actionIndex] ?? null
+ : null;
+ if (!action) return { state, action: null };
+ const result = dismissActiveDialog(state, expectedId);
+ return {
+ state: result.state,
+ action,
+ };
+}
diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx
index ba9f948..b2c60b2 100644
--- a/src/components/library/PlaylistsView.tsx
+++ b/src/components/library/PlaylistsView.tsx
@@ -3,7 +3,6 @@ import {
View,
Pressable,
StyleSheet,
- Alert,
type NativeScrollEvent,
type NativeSyntheticEvent
} from 'react-native';
@@ -17,6 +16,7 @@ import {
AppSheetTitle
} from '@/components/sheets/AppSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { PlaylistRow } from '@/components/library/PlaylistRow';
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
import {
@@ -67,13 +67,13 @@ export function PlaylistsView({
try {
const result = await exportM3u(target);
if (result) {
- Alert.alert(
- 'Playlist exported',
- `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`
- );
+ showAppDialog({
+ title: 'Playlist exported',
+ message: `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`,
+ });
}
} catch (err) {
- Alert.alert('Export failed', errorMessage(err));
+ showAppDialog({ title: 'Export failed', message: errorMessage(err) });
}
};
@@ -85,17 +85,28 @@ export function PlaylistsView({
const parts = [`${matched} of ${summary.total} entries matched the library`];
if (summary.missing > 0) parts.push(`${summary.missing} kept as missing`);
if (summary.ambiguous > 0) parts.push(`${summary.ambiguous} ambiguous`);
- Alert.alert(`Imported "${summary.name}"`, `${parts.join(', ')}.`);
+ showAppDialog({
+ title: `Imported "${summary.name}"`,
+ message: `${parts.join(', ')}.`,
+ });
} catch (err) {
- Alert.alert('Import failed', errorMessage(err));
+ showAppDialog({ title: 'Import failed', message: errorMessage(err) });
}
};
const confirmDelete = (playlist: Playlist) => {
- Alert.alert('Delete playlist?', `"${playlist.name}" will be deleted. Tracks are not touched.`, [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Delete', style: 'destructive', onPress: () => void deletePlaylist(playlist.id) },
- ]);
+ showAppDialog({
+ title: 'Delete playlist?',
+ message: `"${playlist.name}" will be deleted. Tracks are not touched.`,
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ {
+ label: 'Delete',
+ role: 'destructive',
+ onPress: () => void deletePlaylist(playlist.id),
+ },
+ ],
+ });
};
const menuItems =
diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx
index ec0de61..670d990 100644
--- a/src/components/settings/SettingsPanels.tsx
+++ b/src/components/settings/SettingsPanels.tsx
@@ -1,5 +1,4 @@
import {
- Alert,
Pressable,
StyleSheet,
View,
@@ -27,6 +26,7 @@ import { useSettingsStore } from '@/stores/settingsStore';
import { useThemeStore } from '@/stores/themeStore';
import type { LastFmStatus } from '@/types/lastFm';
import { Text } from '@/components/Text';
+import { showAppDialog } from '@/components/dialogs/AppDialog';
import { playHaptic } from '@/lib/haptics';
import type { HomeGreetingTextMode } from '@/home/homeGreeting';
@@ -251,14 +251,18 @@ function LibraryFoldersSettings() {
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
const confirmRemove = (folder: FolderWithCount) => {
- Alert.alert(
- 'Remove folder?',
- `"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
- [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
- ]
- );
+ showAppDialog({
+ title: 'Remove folder?',
+ message: `"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
+ actions: [
+ { label: 'Cancel', role: 'cancel' },
+ {
+ label: 'Remove',
+ role: 'destructive',
+ onPress: () => void removeFolder(folder.id),
+ },
+ ],
+ });
};
return (