mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
replace system boxes
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user