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 (