replace system boxes

This commit is contained in:
Boof2015
2026-07-25 17:00:04 -04:00
parent 0850a64a8f
commit 80c27b039f
16 changed files with 551 additions and 122 deletions
+1
View File
@@ -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",
+27 -14
View File
@@ -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.',
});
}
}, []);
+27 -23
View File
@@ -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
@@ -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);
}
+2
View File
@@ -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() {
/>
</View>
)}
<AppDialogHost />
</SafeAreaProvider>
</GestureHandlerRootView>
);
+9 -5
View File
@@ -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) => {
+10 -10
View File
@@ -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';
+17 -13
View File
@@ -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) => {
+5 -2
View File
@@ -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}.`,
});
}
}
+18 -17
View File
@@ -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 (
+10 -10
View File
@@ -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<RemoteSourceRow | null>(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
+198
View File
@@ -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 (
<Modal
visible
transparent
animationType="fade"
statusBarTranslucent
navigationBarTranslucent
onRequestClose={dismiss}
>
<View style={styles.backdrop}>
<View
style={styles.card}
accessibilityViewIsModal
importantForAccessibility="yes"
onAccessibilityEscape={dismiss}
>
<ScrollView
style={styles.scroll}
bounces={false}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.content}
>
<Text variant="heading" accessibilityRole="header">
{active.title}
</Text>
{active.message ? (
<Text variant="body" color={colors.textSecondary} style={styles.message}>
{active.message}
</Text>
) : null}
</ScrollView>
<View style={styles.actions}>
{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 (
<Pressable
key={`${action.label}-${index}`}
android_ripple={ripple.bounded}
style={({ pressed }) => [
styles.action,
pressed ? styles.actionPressed : null,
]}
onPress={() => selectAction(index)}
accessibilityRole="button"
accessibilityLabel={action.label}
>
<Text variant="body" color={color}>
{action.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
</Modal>
);
}
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,
},
}));
@@ -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);
});
+90
View File
@@ -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,
};
}
+23 -12
View File
@@ -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 =
+13 -9
View File
@@ -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 (