mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 13:09:46 +02:00
replace system boxes
This commit is contained in:
@@ -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: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: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: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: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: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",
|
"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
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
InteractionManager,
|
InteractionManager,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
} from 'expo-file-system/legacy';
|
} from 'expo-file-system/legacy';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { EQGraph } from '@/components/eq/EQGraph';
|
import { EQGraph } from '@/components/eq/EQGraph';
|
||||||
import { BandStrip } from '@/components/eq/BandStrip';
|
import { BandStrip } from '@/components/eq/BandStrip';
|
||||||
import { BandDetailPanel, type EQEditableValue } from '@/components/eq/BandDetailPanel';
|
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.') => {
|
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) => {
|
const deletePreset = useCallback((preset: EQPreset) => {
|
||||||
@@ -188,14 +188,18 @@ export default function EQScreen() {
|
|||||||
finishDelete();
|
finishDelete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
`Delete ${preset.name}?`,
|
title: `Delete ${preset.name}?`,
|
||||||
`This will also clear ${assignmentCount} device assignment${assignmentCount === 1 ? '' : 's'}. The current sound will not change.`,
|
message: `This will also clear ${assignmentCount} device assignment${assignmentCount === 1 ? '' : 's'}. The current sound will not change.`,
|
||||||
[
|
actions: [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
{ text: 'Delete', style: 'destructive', onPress: finishDelete },
|
{
|
||||||
]
|
label: 'Delete',
|
||||||
);
|
role: 'destructive',
|
||||||
|
onPress: finishDelete,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => {
|
const runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => {
|
||||||
@@ -211,18 +215,24 @@ export default function EQScreen() {
|
|||||||
EQ_PRESET_MIME_TYPE
|
EQ_PRESET_MIME_TYPE
|
||||||
);
|
);
|
||||||
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
|
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
|
||||||
Alert.alert('Preset exported', `Saved ${fileName}.`);
|
showAppDialog({ title: 'Preset exported', message: `Saved ${fileName}.` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'share') {
|
if (action === 'share') {
|
||||||
const available = await Sharing.isAvailableAsync();
|
const available = await Sharing.isAvailableAsync();
|
||||||
if (!available) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!cacheDirectory) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const fileUri = `${cacheDirectory}${buildEQPresetFileName(preset.name)}`;
|
const fileUri = `${cacheDirectory}${buildEQPresetFileName(preset.name)}`;
|
||||||
@@ -238,7 +248,10 @@ export default function EQScreen() {
|
|||||||
setQrPreset({ name: preset.name, value: encodeEQPresetQr(preset) });
|
setQrPreset({ name: preset.name, value: encodeEQPresetQr(preset) });
|
||||||
setSheet('qr');
|
setSheet('qr');
|
||||||
} catch {
|
} 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.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet
|
||||||
Alert
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
AppSheetTitle
|
AppSheetTitle
|
||||||
} from '@/components/sheets/AppSheet';
|
} from '@/components/sheets/AppSheet';
|
||||||
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
|
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
|
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
|
||||||
import { spacing } from '@/theme';
|
import { spacing } from '@/theme';
|
||||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
@@ -199,34 +199,38 @@ export default function PlaylistScreen() {
|
|||||||
try {
|
try {
|
||||||
const result = await exportM3u(target);
|
const result = await exportM3u(target);
|
||||||
if (result) {
|
if (result) {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
'Playlist exported',
|
title: 'Playlist exported',
|
||||||
`Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`
|
message: `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Alert.alert('Export failed', errorMessage(err));
|
showAppDialog({ title: 'Export failed', message: errorMessage(err) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (target: Playlist) => {
|
const confirmDelete = (target: Playlist) => {
|
||||||
Alert.alert('Delete playlist?', `"${target.name}" will be deleted. Tracks are not touched.`, [
|
showAppDialog({
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
title: 'Delete playlist?',
|
||||||
{
|
message: `"${target.name}" will be deleted. Tracks are not touched.`,
|
||||||
text: 'Delete',
|
actions: [
|
||||||
style: 'destructive',
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
onPress: () => {
|
{
|
||||||
void (async () => {
|
label: 'Delete',
|
||||||
try {
|
role: 'destructive',
|
||||||
await deletePlaylist(target.id);
|
onPress: () => {
|
||||||
goBack();
|
void (async () => {
|
||||||
} catch (err) {
|
try {
|
||||||
Alert.alert('Delete failed', errorMessage(err));
|
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
|
// Move/remove only exist on real playlists; favorites rows use the standard
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -19,6 +18,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
|
|||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import {
|
import {
|
||||||
AppSheet,
|
AppSheet,
|
||||||
AppSheetItem,
|
AppSheetItem,
|
||||||
@@ -784,7 +784,10 @@ export default function DynamicPlaylistEditorScreen() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (!didCancel) {
|
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();
|
router.back();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -864,10 +867,14 @@ export default function DynamicPlaylistEditorScreen() {
|
|||||||
apply();
|
apply();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Alert.alert('Replace rules?', 'This preset will replace the current filters and result order.', [
|
showAppDialog({
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
title: 'Replace rules?',
|
||||||
{ text: 'Replace', style: 'destructive', onPress: apply },
|
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) => {
|
const openFieldPicker = (target: 'new' | ConditionEditorTarget) => {
|
||||||
@@ -961,7 +968,10 @@ export default function DynamicPlaylistEditorScreen() {
|
|||||||
const createdPlaylist = await createDynamicPlaylist(trimmedName, normalizedRules);
|
const createdPlaylist = await createDynamicPlaylist(trimmedName, normalizedRules);
|
||||||
router.replace(`/library/playlist/${createdPlaylist.id}`);
|
router.replace(`/library/playlist/${createdPlaylist.id}`);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { SessionLifecycle } from '@/session/SessionLifecycle';
|
|||||||
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||||
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||||
import { Text } from '@/components/Text';
|
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
|
// 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
|
// (the widget's `recently-played`, the notification-click redirect) builds
|
||||||
@@ -459,6 +460,7 @@ export default function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<AppDialogHost />
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -23,6 +22,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { AstraLogo } from '@/components/AstraLogo';
|
import { AstraLogo } from '@/components/AstraLogo';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
spacing,
|
spacing,
|
||||||
@@ -339,10 +339,14 @@ export default function DesktopRemoteScreen() {
|
|||||||
}, [connection, discoveryAvailable, discoveryRunning, message]);
|
}, [connection, discoveryAvailable, discoveryRunning, message]);
|
||||||
|
|
||||||
const confirmForget = () => {
|
const confirmForget = () => {
|
||||||
Alert.alert('Forget desktop?', 'This removes the saved desktop pairing from this phone.', [
|
showAppDialog({
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
title: 'Forget desktop?',
|
||||||
{ text: 'Forget', style: 'destructive', onPress: () => void forget() },
|
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) => {
|
const pairDiscovered = (desktop: DesktopRemoteDiscoveredDesktop) => {
|
||||||
|
|||||||
+10
-10
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -14,6 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
spacing,
|
spacing,
|
||||||
@@ -176,21 +176,21 @@ export default function LastFmEditScreen() {
|
|||||||
|
|
||||||
const onRemove = () => {
|
const onRemove = () => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
`Remove ${editing.name}?`,
|
title: `Remove ${editing.name}?`,
|
||||||
'This deletes the scrobble destination and its queued scrobbles from this device. Your history on the service is unaffected.',
|
message: 'This deletes the scrobble destination and its queued scrobbles from this device. Your history on the service is unaffected.',
|
||||||
[
|
actions: [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Remove',
|
label: 'Remove',
|
||||||
style: 'destructive',
|
role: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
void deleteCustomProfile(editing.id);
|
void deleteCustomProfile(editing.id);
|
||||||
router.back();
|
router.back();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const backLabel = step === 'details' && !editing ? 'Service' : 'Scrobbling';
|
const backLabel = step === 'details' && !editing ? 'Service' : 'Scrobbling';
|
||||||
|
|||||||
+17
-13
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -11,6 +10,7 @@ import { useRouter } from 'expo-router';
|
|||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { HapticSwitch } from '@/components/HapticSwitch';
|
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
spacing,
|
spacing,
|
||||||
@@ -66,24 +66,28 @@ export default function LastFmScreen() {
|
|||||||
|
|
||||||
const connectOfficial = (profile: LastFmProfileStatus) => {
|
const connectOfficial = (profile: LastFmProfileStatus) => {
|
||||||
if (status && !status.hasApiCredentials) {
|
if (status && !status.hasApiCredentials) {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
'Last.fm not configured',
|
title: '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.'
|
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;
|
return;
|
||||||
}
|
}
|
||||||
void beginAuth(profile.id);
|
void beginAuth(profile.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDisconnect = (profile: LastFmProfileStatus) => {
|
const confirmDisconnect = (profile: LastFmProfileStatus) => {
|
||||||
Alert.alert(`Disconnect ${profile.name}?`, 'Astra will stop scrobbling to this destination.', [
|
showAppDialog({
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
title: `Disconnect ${profile.name}?`,
|
||||||
{
|
message: 'Astra will stop scrobbling to this destination.',
|
||||||
text: 'Disconnect',
|
actions: [
|
||||||
style: 'destructive',
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
onPress: () => void disconnectProfile(profile.id),
|
{
|
||||||
},
|
label: 'Disconnect',
|
||||||
]);
|
role: 'destructive',
|
||||||
|
onPress: () => void disconnectProfile(profile.id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOfficial = (profile: LastFmProfileStatus) => {
|
const renderOfficial = (profile: LastFmProfileStatus) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Linking,
|
Linking,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
SettingsSectionScreen,
|
SettingsSectionScreen,
|
||||||
} from '@/components/settings/SettingsSectionScaffold';
|
} from '@/components/settings/SettingsSectionScaffold';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { createBuildInfo } from '@/release/buildInfo';
|
import { createBuildInfo } from '@/release/buildInfo';
|
||||||
import { spacing } from '@/theme';
|
import { spacing } from '@/theme';
|
||||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
@@ -26,7 +26,10 @@ async function openExternalLink(url: string, label: string) {
|
|||||||
try {
|
try {
|
||||||
await Linking.openURL(url);
|
await Linking.openURL(url);
|
||||||
} catch {
|
} catch {
|
||||||
Alert.alert('Unable to open link', `Astra could not open ${label}.`);
|
showAppDialog({
|
||||||
|
title: 'Unable to open link',
|
||||||
|
message: `Astra could not open ${label}.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||||
import {
|
import {
|
||||||
SettingsNavRow,
|
SettingsNavRow,
|
||||||
@@ -80,35 +81,35 @@ export default function TroubleshootingSettingsScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmRebuild = () => {
|
const confirmRebuild = () => {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
'Rebuild local library index?',
|
title: 'Rebuild local library index?',
|
||||||
'A foreground scan will re-read every local track. Folders, playlists, favorites, history, remote sources, and settings are preserved.',
|
message: 'A foreground scan will re-read every local track. Folders, playlists, favorites, history, remote sources, and settings are preserved.',
|
||||||
[
|
actions: [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Rebuild',
|
label: 'Rebuild',
|
||||||
onPress: () => void run(
|
onPress: () => void run(
|
||||||
'rebuild',
|
'rebuild',
|
||||||
() => useLibraryStore.getState().rebuildLocalIndex(),
|
() => useLibraryStore.getState().rebuildLocalIndex(),
|
||||||
'Local library index rebuilt.',
|
'Local library index rebuilt.',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmOnboarding = () => {
|
const confirmOnboarding = () => {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
'Replay onboarding?',
|
title: 'Replay onboarding?',
|
||||||
'The first-run setup opens immediately. Your library and settings are kept.',
|
message: 'The first-run setup opens immediately. Your library and settings are kept.',
|
||||||
[
|
actions: [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Replay',
|
label: 'Replay',
|
||||||
onPress: () => void run('onboarding', () => useOnboardingStore.getState().reset(), 'Opening onboarding…'),
|
onPress: () => void run('onboarding', () => useOnboardingStore.getState().reset(), 'Opening onboarding…'),
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+10
-10
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -10,6 +9,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
|
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
@@ -52,18 +52,18 @@ export default function SourcesScreen() {
|
|||||||
const [actionFor, setActionFor] = useState<RemoteSourceRow | null>(null);
|
const [actionFor, setActionFor] = useState<RemoteSourceRow | null>(null);
|
||||||
|
|
||||||
const confirmRemove = (source: RemoteSourceRow) => {
|
const confirmRemove = (source: RemoteSourceRow) => {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
`Remove ${source.name}?`,
|
title: `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.',
|
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: [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Remove',
|
label: 'Remove',
|
||||||
style: 'destructive',
|
role: 'destructive',
|
||||||
onPress: () => void deleteSource(source.id, true),
|
onPress: () => void deleteSource(source.id, true),
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionItems: ActionSheetItem[] = actionFor
|
const actionItems: ActionSheetItem[] = actionFor
|
||||||
|
|||||||
@@ -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,
|
View,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Alert,
|
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
type NativeSyntheticEvent
|
type NativeSyntheticEvent
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
AppSheetTitle
|
AppSheetTitle
|
||||||
} from '@/components/sheets/AppSheet';
|
} from '@/components/sheets/AppSheet';
|
||||||
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
|
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { PlaylistRow } from '@/components/library/PlaylistRow';
|
import { PlaylistRow } from '@/components/library/PlaylistRow';
|
||||||
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
|
import { PullSearchScrollView } from '@/components/search/PullSearchGesture';
|
||||||
import {
|
import {
|
||||||
@@ -67,13 +67,13 @@ export function PlaylistsView({
|
|||||||
try {
|
try {
|
||||||
const result = await exportM3u(target);
|
const result = await exportM3u(target);
|
||||||
if (result) {
|
if (result) {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
'Playlist exported',
|
title: 'Playlist exported',
|
||||||
`Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`
|
message: `Wrote ${result.entryCount} ${result.entryCount === 1 ? 'entry' : 'entries'} to "${fileDisplayName(result.fileUri)}".`,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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`];
|
const parts = [`${matched} of ${summary.total} entries matched the library`];
|
||||||
if (summary.missing > 0) parts.push(`${summary.missing} kept as missing`);
|
if (summary.missing > 0) parts.push(`${summary.missing} kept as missing`);
|
||||||
if (summary.ambiguous > 0) parts.push(`${summary.ambiguous} ambiguous`);
|
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) {
|
} catch (err) {
|
||||||
Alert.alert('Import failed', errorMessage(err));
|
showAppDialog({ title: 'Import failed', message: errorMessage(err) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = (playlist: Playlist) => {
|
const confirmDelete = (playlist: Playlist) => {
|
||||||
Alert.alert('Delete playlist?', `"${playlist.name}" will be deleted. Tracks are not touched.`, [
|
showAppDialog({
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
title: 'Delete playlist?',
|
||||||
{ text: 'Delete', style: 'destructive', onPress: () => void deletePlaylist(playlist.id) },
|
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 =
|
const menuItems =
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
@@ -27,6 +26,7 @@ import { useSettingsStore } from '@/stores/settingsStore';
|
|||||||
import { useThemeStore } from '@/stores/themeStore';
|
import { useThemeStore } from '@/stores/themeStore';
|
||||||
import type { LastFmStatus } from '@/types/lastFm';
|
import type { LastFmStatus } from '@/types/lastFm';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { showAppDialog } from '@/components/dialogs/AppDialog';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
import type { HomeGreetingTextMode } from '@/home/homeGreeting';
|
import type { HomeGreetingTextMode } from '@/home/homeGreeting';
|
||||||
|
|
||||||
@@ -251,14 +251,18 @@ function LibraryFoldersSettings() {
|
|||||||
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
|
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
|
||||||
|
|
||||||
const confirmRemove = (folder: FolderWithCount) => {
|
const confirmRemove = (folder: FolderWithCount) => {
|
||||||
Alert.alert(
|
showAppDialog({
|
||||||
'Remove folder?',
|
title: 'Remove folder?',
|
||||||
`"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
|
message: `"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
|
||||||
[
|
actions: [
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
{ label: 'Cancel', role: 'cancel' },
|
||||||
{ text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
|
{
|
||||||
]
|
label: 'Remove',
|
||||||
);
|
role: 'destructive',
|
||||||
|
onPress: () => void removeFolder(folder.id),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user