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
+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 (