mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-15 19:57:08 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f4405931a | ||
|
|
a1078cb12f |
No files matched your search
@@ -488,6 +488,30 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Render Compose previews
|
||||
run: ./gradlew testDebugUnitTest --tests '*PreviewRenderTest' --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
- name: Check the native dependency closure
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APK=$(find app/build/outputs/apk -name '*.apk' | head -1)
|
||||
WORK=$(mktemp -d)
|
||||
unzip -q "$APK" 'lib/arm64-v8a/*' -d "$WORK"
|
||||
cd "$WORK/lib/arm64-v8a"
|
||||
SYSTEM="libc.so libm.so libdl.so liblog.so libandroid.so libz.so libvulkan.so libstdc++.so
|
||||
libGLESv2.so libEGL.so libnativewindow.so libjnigraphics.so libaaudio.so
|
||||
libOpenSLES.so libmediandk.so libcamera2ndk.so libsync.so libneuralnetworks.so"
|
||||
fail=0
|
||||
for so in *.so; do
|
||||
for need in $(readelf -d "$so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p'); do
|
||||
[ -f "$need" ] && continue
|
||||
case " $(echo $SYSTEM) " in *" $need "*) continue ;; esac
|
||||
echo "::error::$so needs $need, which is neither in the apk nor a platform library"
|
||||
fail=1
|
||||
done
|
||||
done
|
||||
exit $fail
|
||||
|
||||
- name: Sign with the testkey (v1 + v2 + v3)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -286,6 +286,10 @@ dependencies {
|
||||
// on-screen pad are Views underneath it, which is what they are in every Compose app.
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
testImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
// The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.droiddeck.launcher.ui.DroidDeckPreviews
|
||||
import com.droiddeck.launcher.ui.PreviewCase
|
||||
import com.droiddeck.launcher.ui.PreviewHost
|
||||
import java.io.File
|
||||
|
||||
/** File metadata only: no files are created and no real directory is enumerated. */
|
||||
private class SampleFile(path: String, private val directory: Boolean = false, private val bytes: Long = 0) : File(path) {
|
||||
override fun isDirectory() = directory
|
||||
override fun isFile() = !directory
|
||||
override fun exists() = true
|
||||
override fun canRead() = true
|
||||
override fun canWrite() = false
|
||||
override fun length() = bytes
|
||||
override fun getUsableSpace() = 32_000_000_000L
|
||||
override fun getFreeSpace() = 32_000_000_000L
|
||||
override fun lastModified() = 1_791_331_200_000L
|
||||
override fun getCanonicalPath() = absolutePath
|
||||
override fun getCanonicalFile() = this
|
||||
override fun listFiles(): Array<File> = emptyArray()
|
||||
}
|
||||
|
||||
private val directory = SampleFile("/storage/emulated/0", directory = true)
|
||||
private val entries = listOf(
|
||||
SampleFile("/storage/emulated/0/Games", directory = true),
|
||||
SampleFile("/storage/emulated/0/ROMs", directory = true),
|
||||
SampleFile("/storage/emulated/0/Downloads", directory = true),
|
||||
SampleFile("/storage/emulated/0/Portal 2.exe", bytes = 4_200_000),
|
||||
SampleFile("/storage/emulated/0/session.log", bytes = 32_000),
|
||||
SampleFile("/storage/emulated/0/save.zip", bytes = 12_000_000),
|
||||
)
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FileGridPreview() = PreviewHost {
|
||||
FileManagerScreen(preview = FileManagerPreviewState(directory, entries))
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FileListPreview() = PreviewHost {
|
||||
FileManagerScreen(preview = FileManagerPreviewState(directory, entries, grid = false))
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FileEmptyPreview() = PreviewHost {
|
||||
FileManagerScreen(preview = FileManagerPreviewState(directory, emptyList()))
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FilePickerPreview() = PreviewHost {
|
||||
FileManagerScreen(pickMode = true, pickExtensions = listOf("exe"), pickerTitle = "Choose executable", onPick = {},
|
||||
preview = FileManagerPreviewState(directory, entries.filter { it.isDirectory || it.extension == "exe" }))
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FolderPickerPreview() = PreviewHost {
|
||||
FileManagerScreen(pickMode = true, pickDirMode = true, onPick = {},
|
||||
preview = FileManagerPreviewState(directory, entries.filter { it.isDirectory }))
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FilePropertiesPreview() = PreviewHost {
|
||||
FilePropertiesDialog(entries.last(), {}, {})
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun FavoritesPreview() = PreviewHost {
|
||||
FileManagerScreen(preview = FileManagerPreviewState(directory, entries, favorites = true))
|
||||
}
|
||||
|
||||
internal val filePreviewCases = listOf(
|
||||
PreviewCase("Favorites") { FavoritesPreview() },
|
||||
PreviewCase("FileGrid") { FileGridPreview() },
|
||||
PreviewCase("FileList") { FileListPreview() },
|
||||
PreviewCase("FileEmpty") { FileEmptyPreview() },
|
||||
PreviewCase("FilePicker") { FilePickerPreview() },
|
||||
PreviewCase("FolderPicker") { FolderPickerPreview() },
|
||||
PreviewCase("FileProperties") { FilePropertiesPreview() },
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun WirelessAdbPreview() {
|
||||
PreviewHost {
|
||||
WirelessAdbFixPage({}, true, {}, { _, _, _, done -> done("Preview only") }, { _, done -> done(null) }, { _, _, _, done -> done("Preview only") })
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ProcessLimitPreview() {
|
||||
PreviewHost {
|
||||
PhantomProcessGatePage(PhantomProcessStatus.ENABLED, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun AddAppPreview() {
|
||||
PreviewHost {
|
||||
AddAppDialog(true, {}) { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun EditAppPreview() {
|
||||
PreviewHost {
|
||||
EditAppDialog(UserApps.App("sample", UserApps.Kind.APPIMAGE, "Example app", null, "AppImage", "/preview/example.AppImage", emptyList()), {}) { _, _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ConfirmationPreview() {
|
||||
PreviewHost {
|
||||
ConfirmDialog("Remove runtime?", "Remove the installed Linux runtime.", "Remove", {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun CustomResolutionPreview() {
|
||||
PreviewHost {
|
||||
CustomResolutionDialog(1280 to 720, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun RomsFolderPreview() {
|
||||
PreviewHost {
|
||||
RomsDialog("/preview/ROMs", {}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun GameEnvironmentPreview() {
|
||||
PreviewHost {
|
||||
GameEnvironmentEditor(false, com.droiddeck.launcher.core.GameEnvironment.Config(shared = mapOf("DXVK_HUD" to "fps", "MANGOHUD" to "1"))) {}
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun WindowsComponentsPreview() {
|
||||
PreviewHost {
|
||||
WinComponentsDialog("100", "Portal 2", null, false, onClose = {}, previewComponents = listOf(
|
||||
com.droiddeck.launcher.session.WinComponents.Component("vcrun2022", "Visual C++ runtime", "Microsoft", "ready", emptyList(), emptyList()),
|
||||
com.droiddeck.launcher.session.WinComponents.Component("d3dcompiler_47", "DirectX shader compiler", "Microsoft", "ready", emptyList(), emptyList()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DeveloperDisplayPreview() = PreviewHost {
|
||||
DeveloperDisplayChoiceDialog(listOf(2 to "Secondary screen"), {}, {}, {})
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun AppDisplayPreview() = PreviewHost {
|
||||
ChooseAppDisplayDialog(com.droiddeck.launcher.HomeApp.LaunchableApp("Files", "preview.files", "preview.files.Main", null),
|
||||
com.droiddeck.launcher.input.SecondScreenDisplay(2, "Secondary screen"), {}, {}, {})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun LauncherPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(), PreviewData.frontActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun RuntimeMissingPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(ready = false), PreviewData.frontActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun RuntimeDownloadingPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(ready = false, busy = true), PreviewData.frontActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun LibraryPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "games")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun EmptyLibraryPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(empty = true), PreviewData.frontActions, initialSelection = "games")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun GameDetailsPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "app:100")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DesktopPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "desktop")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun EmulatorPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "emu:dolphin")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ArtPreview() {
|
||||
PreviewHost {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp)) {
|
||||
ArtGrid(PreviewData.games.map { Tile(it.name, "Installed", null, it.gameIdString, onClick = {}) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun UpdatesPreview() {
|
||||
PreviewHost {
|
||||
UpdatesPage(PreviewData.front(updates = UpdatesState(catalog = PreviewData.updatesCatalog)), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun UpdatesDownloadingPreview() {
|
||||
PreviewHost {
|
||||
UpdatesPage(PreviewData.front(updates = UpdatesState(catalog = PreviewData.updatesCatalog, stage = "Downloading", percent = 42)), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun UpdatesErrorPreview() {
|
||||
PreviewHost {
|
||||
UpdatesPage(PreviewData.front(updates = UpdatesState(error = "Could not check for updates")), PreviewData.frontActions, Modifier.fillMaxSize().padding(24.dp), installed = PreviewData.installed)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Paper theme", widthDp = 960, heightDp = 540, apiLevel = 34)
|
||||
@Composable
|
||||
internal fun AlternateThemePreview() {
|
||||
PreviewHost(theme = Themes.PAPER) { FrontEndScreen(PreviewData.front(), PreviewData.frontActions) }
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun StorePreview() = PreviewHost {
|
||||
val apps = listOf(
|
||||
com.droiddeck.launcher.store.FlathubApi.AppSummary("org.videolan.VLC", "VLC", "Media player", null, "VideoLAN", true, 100_000),
|
||||
com.droiddeck.launcher.store.FlathubApi.AppSummary("org.mozilla.firefox", "Firefox", "Web browser", null, "Mozilla", true, 100_000),
|
||||
)
|
||||
StorePage(PreviewData.front(), PreviewData.frontActions, Modifier.fillMaxSize(), previewSections = mapOf("popular" to apps, "trending" to apps))
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun AndroidAppsPreview() = PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(home = true), PreviewData.frontActions, initialSelection = "android-apps")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
internal class PreviewCase(val name: String, val content: @Composable () -> Unit) {
|
||||
override fun toString() = name
|
||||
}
|
||||
|
||||
internal val screenPreviewCases = listOf(
|
||||
PreviewCase("AlternateTheme") { AlternateThemePreview() },
|
||||
PreviewCase("Launcher") { LauncherPreview() },
|
||||
PreviewCase("RuntimeMissing") { RuntimeMissingPreview() },
|
||||
PreviewCase("RuntimeDownloading") { RuntimeDownloadingPreview() },
|
||||
PreviewCase("Library") { LibraryPreview() },
|
||||
PreviewCase("EmptyLibrary") { EmptyLibraryPreview() },
|
||||
PreviewCase("GameDetails") { GameDetailsPreview() },
|
||||
PreviewCase("Desktop") { DesktopPreview() },
|
||||
PreviewCase("Emulator") { EmulatorPreview() },
|
||||
PreviewCase("Setup") { SetupPreview() },
|
||||
PreviewCase("ControllerMapping") { ControllerMappingPreview() },
|
||||
PreviewCase("GpuDrivers") { GpuDriversPreview() },
|
||||
PreviewCase("Protons") { ProtonsPreview() },
|
||||
PreviewCase("ProtonDownload") { ProtonDownloadPreview() },
|
||||
PreviewCase("ProtonsEmpty") { ProtonsEmptyPreview() },
|
||||
PreviewCase("Drivers") { DriversPreview() },
|
||||
PreviewCase("Loading") { LoadingPreview() },
|
||||
PreviewCase("SessionStarting") { SessionStartingPreview() },
|
||||
PreviewCase("SessionError") { SessionErrorPreview() },
|
||||
PreviewCase("Keyboard") { KeyboardPreview() },
|
||||
PreviewCase("Hud") { HudPreview() },
|
||||
PreviewCase("WirelessAdb") { WirelessAdbPreview() },
|
||||
PreviewCase("ProcessLimit") { ProcessLimitPreview() },
|
||||
PreviewCase("AddApp") { AddAppPreview() },
|
||||
PreviewCase("EditApp") { EditAppPreview() },
|
||||
PreviewCase("Confirmation") { ConfirmationPreview() },
|
||||
PreviewCase("CustomResolution") { CustomResolutionPreview() },
|
||||
PreviewCase("RomsFolder") { RomsFolderPreview() },
|
||||
PreviewCase("GameEnvironment") { GameEnvironmentPreview() },
|
||||
PreviewCase("WindowsComponents") { WindowsComponentsPreview() },
|
||||
PreviewCase("Controls") { ControlsPreview() },
|
||||
PreviewCase("Widgets") { WidgetsPreview() },
|
||||
PreviewCase("Art") { ArtPreview() },
|
||||
PreviewCase("Cursor") { CursorPreview() },
|
||||
PreviewCase("SteamDisplay") { SteamDisplayPreview() },
|
||||
PreviewCase("SteamControls") { SteamControlsPreview() },
|
||||
PreviewCase("SteamGames") { SteamGamesPreview() },
|
||||
PreviewCase("SteamAudio") { SteamAudioPreview() },
|
||||
PreviewCase("SteamSession") { SteamSessionPreview() },
|
||||
PreviewCase("SteamSteam") { SteamSteamPreview() },
|
||||
PreviewCase("DesktopSettings") { DesktopSettingsPreview() },
|
||||
PreviewCase("DrawerController") { DrawerControllerPreview() },
|
||||
PreviewCase("DrawerSecondScreen") { DrawerSecondScreenPreview() },
|
||||
PreviewCase("DrawerDisplay") { DrawerDisplayPreview() },
|
||||
PreviewCase("DrawerEffects") { DrawerEffectsPreview() },
|
||||
PreviewCase("DrawerGames") { DrawerGamesPreview() },
|
||||
PreviewCase("DrawerSession") { DrawerSessionPreview() },
|
||||
PreviewCase("Performance") { PerformancePreview() },
|
||||
PreviewCase("Components") { ComponentsPreview() },
|
||||
PreviewCase("ComponentsLoading") { ComponentsLoadingPreview() },
|
||||
PreviewCase("GpuComponents") { GpuComponentsPreview() },
|
||||
PreviewCase("Updates") { UpdatesPreview() },
|
||||
PreviewCase("UpdatesDownloading") { UpdatesDownloadingPreview() },
|
||||
PreviewCase("UpdatesError") { UpdatesErrorPreview() },
|
||||
PreviewCase("Store") { StorePreview() },
|
||||
PreviewCase("DeveloperDisplay") { DeveloperDisplayPreview() },
|
||||
PreviewCase("AppDisplay") { AppDisplayPreview() },
|
||||
PreviewCase("AndroidApps") { AndroidAppsPreview() },
|
||||
PreviewCase("SetupController") { SetupControllerPreview() },
|
||||
PreviewCase("SetupSession") { SetupSessionPreview() },
|
||||
PreviewCase("SetupLauncher") { SetupLauncherPreview() },
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.update.AppUpdates
|
||||
import java.io.File
|
||||
|
||||
/** Deterministic fixtures shared by Studio previews and the render smoke tests. */
|
||||
internal object PreviewData {
|
||||
val games = listOf("Portal 2", "Celeste", "Stardew Valley", "Hollow Knight", "A Short Hike", "Dead Cells")
|
||||
.mapIndexed { i, name -> Library.SteamGame(100 + i, name, null, "/preview/steam") }
|
||||
val controller = ControllerPrefs.Settings(0xFF1A9FFF.toInt(), 80, 100, true, true, true, true, true, true, false, emptyMap())
|
||||
fun front(empty: Boolean = false, busy: Boolean = false, ready: Boolean = true, home: Boolean = false, updates: UpdatesState = UpdatesState()) = FrontEndState(
|
||||
installed = if (ready) "2026.10.07" else null, ready = ready, available = "2026.10.07",
|
||||
busy = busy, stage = if (busy) "Downloading runtime" else "", percent = if (busy) 42 else -1,
|
||||
desktopInstalled = ready, offlineAccount = "Player", offline = false, frameGenLabel = "Off", romsDir = "/preview/ROMs", logsEnabled = true,
|
||||
steamGames = if (empty) emptyList() else games,
|
||||
emulators = listOf(Library.Emulator("dolphin", "Dolphin", "GameCube / Wii", "dolphin-emu", true, listOf(
|
||||
Library.Rom("Wind Waker", File("/preview/ROMs/Wind Waker.iso"), "/root/ROMs/Wind Waker.iso", "dolphin"),
|
||||
))), running = null, controller = controller, animationsEnabled = false, storeEnabled = true,
|
||||
buildLabel = "Design preview", updates = updates, isHomeApp = home, homeScreenEnabled = home,
|
||||
androidApps = if (home) listOf(com.droiddeck.launcher.HomeApp.LaunchableApp("Files", "preview.files", "preview.files.Main", null),
|
||||
com.droiddeck.launcher.HomeApp.LaunchableApp("Settings", "preview.settings", "preview.settings.Main", null)) else emptyList(),
|
||||
)
|
||||
val frontActions = FrontEndActions(
|
||||
onPlay = {},
|
||||
onPlayDesktopUi = {},
|
||||
onSteamGame = {},
|
||||
onDesktop = {},
|
||||
onEmulator = {},
|
||||
onRom = {},
|
||||
onResume = {},
|
||||
onSteamSettings = {},
|
||||
onDesktopSettings = {},
|
||||
onInstallPackage = {},
|
||||
onRemovePackage = {},
|
||||
onRuntime = {},
|
||||
onFrameGenPick = {},
|
||||
onImportLossless = {},
|
||||
onProtons = {},
|
||||
onComponents = {},
|
||||
onPerformance = {},
|
||||
onRoms = {},
|
||||
onFiles = {},
|
||||
onLogs = {},
|
||||
onOffline = {},
|
||||
onWirelessAdbPair = { _, _, _, done -> done("Preview only") },
|
||||
onWirelessAdbApply = { _, _, _, done -> done("Preview only") },
|
||||
onSetPhantomProcessLimit = { _, done -> done("Preview only") },
|
||||
controller = ControllerActions(
|
||||
onOsc = {},
|
||||
onTint = {},
|
||||
onOpacity = {},
|
||||
onSize = {},
|
||||
onStickClick = {},
|
||||
onAdaptiveSticks = {},
|
||||
onRumble = {},
|
||||
onSteamButton = {},
|
||||
onQamButton = {},
|
||||
onKeyboardButton = {},
|
||||
onEditLayout = {},
|
||||
onResetLayout = {},
|
||||
onMapping = {},
|
||||
onResetAll = {},
|
||||
),
|
||||
)
|
||||
fun mode(steam: Boolean = true) = ModeSettings(
|
||||
mode = if (steam) SessionService.MODE_STEAM else SessionService.MODE_DESKTOP, resolution = "1280x720",
|
||||
hdr = false, hdrReason = "HDR is unavailable on this display", touchMode = SessionPrefs.TOUCH_AUTO,
|
||||
suspendPolicy = SessionPrefs.SUSPEND_AUTO, oscMode = SessionPrefs.OSC_AUTO,
|
||||
directAudio = true, mic = false, renderer = "vulkan", stretch16x9 = false,
|
||||
pipSupported = true, syncBackend = "auto", steamChannel = "stable", steamController = "deck",
|
||||
addedGamesDirs = listOf("/preview/Games"), deckyInstalled = "3.0.0", deckyEnabled = true,
|
||||
)
|
||||
val modeActions = ModeSettingsActions(
|
||||
onResolution = {},
|
||||
onHdr = {},
|
||||
onTouch = {},
|
||||
onSuspendPolicy = {},
|
||||
onOsc = {},
|
||||
onDirectAudio = {},
|
||||
onMic = {},
|
||||
onRenderer = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
val components = ComponentsManager.Snapshot(
|
||||
protons = listOf(ComponentsManager.ProtonView(
|
||||
ComponentsManager.Proton("ge-proton", "GE-Proton", File("/preview/proton"), "/opt/proton", "10-20", false),
|
||||
mapOf("fex" to ComponentsManager.Component("FEX-2609", "FEX-2609", "Original", null, null),
|
||||
"dxvk" to ComponentsManager.Component("2.7", "DXVK 2.7", "Original", null, null),
|
||||
"vkd3d" to ComponentsManager.Component("2.14", "VKD3D-Proton 2.14", "Original", null, null)),
|
||||
emptyList(), false,
|
||||
)),
|
||||
packages = listOf(ComponentsManager.Package("dxvk-2.7.tzst", "dxvk", "2.7", "DXVK 2.7", 24_000_000)),
|
||||
)
|
||||
val catalog = listOf(ComponentsManager.CatalogItem("dxvk-2.7.tzst", "dxvk", "2.7", "", 24_000_000, ""))
|
||||
val drivers = listOf(DriverRow("auto", "Auto", "Recommended for this device", false),
|
||||
DriverRow("turnip", "Turnip 25.3", "Android and Linux", true, DriverRow.BUNDLE))
|
||||
val gpu = GpuDriversState(gpuName = "Adreno 740", gpuFamily = "Adreno 7xx", soc = "Snapdragon 8 Gen 2",
|
||||
supportText = "Turnip supported", autoStatus = "Turnip 25.3", activeBundle = "Turnip 25.3",
|
||||
pairs = listOf(PairRow("turnip", "Turnip", "25.3", "Android and Linux", true, true, true, true, true)),
|
||||
androidRows = drivers, linuxRows = drivers)
|
||||
val drawerActions = DrawerActions(
|
||||
steam = true, isHomeApp = false, androidApps = emptyList(), hudOn = true,
|
||||
frameGen = FrameGen.Mode.OFF, lossless = Lossless.State.NONE, oscMode = SessionPrefs.OSC_AUTO,
|
||||
onScreenButtonsVisible = true, suspendPolicy = SessionPrefs.SUSPEND_AUTO, backActionsInverted = false,
|
||||
touchMode = SessionPrefs.TOUCH_AUTO, touchAuto = "Touchpad", fexPreset = "balanced",
|
||||
secondScreenMode = SecondScreenMode.NONE, secondScreenDisplays = listOf(SecondScreenDisplay(2, "Secondary screen")),
|
||||
selectedSecondScreenDisplay = 2, controller = controller, components = components, pipSupported = true,
|
||||
onHud = {},
|
||||
onFrameGenPick = {},
|
||||
onImportLossless = {},
|
||||
onKeyboard = {},
|
||||
onHardwareKeyboard = {},
|
||||
onSteamMenu = {},
|
||||
onQam = {},
|
||||
onOsc = {},
|
||||
onSuspendPolicy = {},
|
||||
onBackActionsInverted = {},
|
||||
onTouch = {},
|
||||
onFexPreset = {},
|
||||
onSecondScreenMode = {},
|
||||
onSecondScreenDisplay = {},
|
||||
onLaunchAndroidApp = { _, _ -> },
|
||||
onBackground = {},
|
||||
onShareLogs = {},
|
||||
onStop = {},
|
||||
onClose = {},
|
||||
)
|
||||
const val TIME = 1_791_331_200_000L
|
||||
val installed = AppUpdates.Installed("preview-old", 0, "0.3.0", 10, true, TIME - 86_400_000, true)
|
||||
val release = AppUpdates.Release("v0.3.1", "Improve handheld layouts", "Settings and library improvements.", "preview-new", 0, "0.3.1", 11, TIME,
|
||||
"", AppUpdates.Apk("DroidDeck.apk", "", 200_000_000, "", "com.droiddeck.launcher", 11, com.droiddeck.launcher.BuildConfig.RELEASE_SIGNER))
|
||||
val updatesCatalog = AppUpdates.Catalog(release, release, emptyList(), TIME,
|
||||
listOf(AppUpdates.PreviewChange("preview-new", "Improve handheld layouts", "Settings and library improvements.", TIME)))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.OnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.activity.result.ActivityResultRegistry
|
||||
import androidx.activity.result.ActivityResultRegistryOwner
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
|
||||
@Preview(name = "Handheld", widthDp = 960, heightDp = 540, apiLevel = 34)
|
||||
@Preview(name = "Compact", widthDp = 480, heightDp = 360, apiLevel = 34)
|
||||
@Preview(name = "Portrait", widthDp = 400, heightDp = 700, apiLevel = 34)
|
||||
@Preview(name = "Large text", widthDp = 960, heightDp = 540, fontScale = 1.3f, apiLevel = 34)
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DroidDeckPreviews
|
||||
|
||||
/** Every preview uses the production theme and inert Android navigation/picker owners. */
|
||||
@Composable
|
||||
internal fun PreviewHost(theme: String = Themes.GRAPHITE, content: @Composable () -> Unit) {
|
||||
val owner = remember { PreviewOwner() }
|
||||
CompositionLocalProvider(
|
||||
LocalInspectionMode provides true,
|
||||
LocalOnBackPressedDispatcherOwner provides owner,
|
||||
LocalActivityResultRegistryOwner provides owner,
|
||||
) {
|
||||
DroidDeckTheme(theme = theme) {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class PreviewOwner : OnBackPressedDispatcherOwner, ActivityResultRegistryOwner, LifecycleOwner {
|
||||
override val lifecycle: Lifecycle = LifecycleRegistry(this).apply { currentState = Lifecycle.State.RESUMED }
|
||||
override val onBackPressedDispatcher = OnBackPressedDispatcher()
|
||||
override val activityResultRegistry = object : ActivityResultRegistry() {
|
||||
override fun <I, O> onLaunch(requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat?) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun GpuDriversPreview() {
|
||||
PreviewHost {
|
||||
GpuDriversPanel(PreviewData.gpu, GpuDriversActions()) {}
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ProtonsPreview() {
|
||||
PreviewHost {
|
||||
ProtonPage(listOf(ProtonRow("ge-proton", "GE-Proton 10-20", "10-20", false), ProtonRow("proton", "Proton Experimental", null, false)), null, null, -1, true, false, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ProtonDownloadPreview() {
|
||||
PreviewHost {
|
||||
ProtonPage(listOf(ProtonRow("ge-proton", "GE-Proton 10-20", null, false)), "ge-proton", "Downloading", 42, true, false, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ProtonsEmptyPreview() {
|
||||
PreviewHost {
|
||||
ProtonPage(emptyList(), null, null, -1, false, false, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DriversPreview() {
|
||||
PreviewHost {
|
||||
DriverPage("GPU drivers", "Choose a driver", PreviewData.drivers, "auto",
|
||||
listOf(DownloadRow("turnip", "Turnip 25.3", "Adreno 7xx", 42)), "Downloading", false, "Import driver", true,
|
||||
{}, {}, {}, {}, {}, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ComponentsPreview() {
|
||||
PreviewHost {
|
||||
ComponentsPage(PreviewData.components, PreviewData.catalog, PreviewData.TIME, "ge-proton", "dxvk", false, null, emptyMap(),
|
||||
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ComponentsLoadingPreview() {
|
||||
PreviewHost {
|
||||
ComponentsPage(null, PreviewData.catalog, PreviewData.TIME, "ge-proton", "dxvk", true, null, emptyMap(),
|
||||
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun GpuComponentsPreview() {
|
||||
PreviewHost {
|
||||
ComponentsPage(PreviewData.components, PreviewData.catalog, PreviewData.TIME, "ge-proton", GPU_TAB, false, null, emptyMap(),
|
||||
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, requestInitialFocus = false, gpu = PreviewData.gpu)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun LoadingPreview() {
|
||||
PreviewHost {
|
||||
LoadingOverlay("Downloading runtime", 42, "00:32", "", false, LoadingState.Topic.RUNTIME, true, onCancel = {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SessionStartingPreview() {
|
||||
PreviewHost {
|
||||
LoadingOverlay("Starting Steam", -1, "00:08", "", false, LoadingState.Topic.STEAM_STARTING, true, onCancel = {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SessionErrorPreview() {
|
||||
PreviewHost {
|
||||
LoadingOverlay("Steam stopped", -1, "00:08", "Try starting Steam again.", true, LoadingState.Topic.STEAM, true, endedDetail = "Exit code 1", onRetry = {}, onShareLogs = {}, onClose = {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun KeyboardPreview() {
|
||||
PreviewHost {
|
||||
PcKeyboard({ _, _ -> }, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun HudPreview() {
|
||||
PreviewHost {
|
||||
HudText("60 FPS • GPU 72% • CPU 38%")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun CursorPreview() {
|
||||
PreviewHost {
|
||||
CursorOverlay(Offset(220f, 180f), true, 2f)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DrawerControllerPreview() {
|
||||
PreviewHost {
|
||||
SessionDrawer(true, SessionDrawerPage.CONTROLLER, false, {}, PreviewData.drawerActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DrawerSecondScreenPreview() {
|
||||
PreviewHost {
|
||||
SessionDrawer(true, SessionDrawerPage.SECOND_SCREEN, false, {}, PreviewData.drawerActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DrawerDisplayPreview() {
|
||||
PreviewHost {
|
||||
SessionDrawer(true, SessionDrawerPage.DISPLAY, false, {}, PreviewData.drawerActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DrawerEffectsPreview() {
|
||||
PreviewHost {
|
||||
SessionDrawer(true, SessionDrawerPage.EFFECTS, false, {}, PreviewData.drawerActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DrawerGamesPreview() {
|
||||
PreviewHost {
|
||||
SessionDrawer(true, SessionDrawerPage.GAMES, false, {}, PreviewData.drawerActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DrawerSessionPreview() {
|
||||
PreviewHost {
|
||||
SessionDrawer(true, SessionDrawerPage.SESSION, false, {}, PreviewData.drawerActions)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SetupPreview() {
|
||||
PreviewHost {
|
||||
FrontEndScreen(PreviewData.front(), PreviewData.frontActions, initialSelection = "setup")
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ControllerMappingPreview() {
|
||||
PreviewHost {
|
||||
ControllerMappingPage(mapOf("a" to "b"), { _, _ -> }, {}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun ControlsPreview() {
|
||||
PreviewHost {
|
||||
val host = rememberMenuHost()
|
||||
SettingsPage(host, "Controller", {}, scroll = rememberScrollState()) {
|
||||
ControllerRows(host, "auto", PreviewData.controller, PreviewData.frontActions.controller!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun WidgetsPreview() {
|
||||
PreviewHost {
|
||||
val host = rememberMenuHost()
|
||||
SettingsPage(host, "Settings controls", {}, scroll = rememberScrollState()) {
|
||||
SettingsGroup("Display") {
|
||||
ChoiceRow(host, "resolution", "Resolution", "Virtual screen size", listOf("720p" to "1280 × 720", "1080p" to "1920 × 1080"), "720p") {}
|
||||
ToggleRow(host, "hdr", "HDR", "High dynamic range", true) {}
|
||||
SliderRow("Sharpness", null, 75, 0..100) {}
|
||||
ActionRow("GPU driver", null, "Manage", {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SteamDisplayPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 0)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SteamControlsPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SteamGamesPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 2)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SteamAudioPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 3)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SteamSessionPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 4)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SteamSteamPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(), PreviewData.modeActions, initialTab = 5)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun DesktopSettingsPreview() {
|
||||
PreviewHost {
|
||||
ModeSettingsPage(PreviewData.mode(steam = false), PreviewData.modeActions)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun PerformancePreview() {
|
||||
PreviewHost {
|
||||
PerformancePage(
|
||||
cores = List(8) { CoreRow(it, if (it < 4) "Efficiency" else "Performance") },
|
||||
clientOverride = false,
|
||||
clientCores = setOf(0, 1, 2, 3),
|
||||
gameCores = (0..7).toSet(),
|
||||
tuSysmem = false,
|
||||
zinkLazy = false,
|
||||
glThread = false,
|
||||
noGlError = false,
|
||||
noXalia = false,
|
||||
fastSync = false,
|
||||
syncFallback = false,
|
||||
fsyncFirst = false,
|
||||
gamescopeRealtime = false,
|
||||
gpuClockPin = false,
|
||||
prootNoSeccomp = false,
|
||||
prootFastPath = false,
|
||||
guestHostname = "droiddeck",
|
||||
phantomWarning = null,
|
||||
onClientOverride = {},
|
||||
onTuSysmem = {},
|
||||
onZinkLazy = {},
|
||||
onGlThread = {},
|
||||
onNoGlError = {},
|
||||
onNoXalia = {},
|
||||
onFastSync = {},
|
||||
onSyncFallback = {},
|
||||
onFsyncFirst = {},
|
||||
onGamescopeRealtime = {},
|
||||
onGpuClockPin = {},
|
||||
onProotNoSeccomp = {},
|
||||
onProotFastPath = {},
|
||||
onGuestHostname = {},
|
||||
onClientCore = { _, _ -> },
|
||||
onGameCore = { _, _ -> },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SetupControllerPreview() = PreviewHost {
|
||||
SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 1)
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SetupSessionPreview() = PreviewHost {
|
||||
SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 2)
|
||||
}
|
||||
|
||||
@DroidDeckPreviews
|
||||
@Composable
|
||||
internal fun SetupLauncherPreview() = PreviewHost {
|
||||
SetupPanel(PreviewData.front(), PreviewData.frontActions, {}, {}, initialTab = 3)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -122,7 +123,8 @@ class RailState internal constructor(
|
||||
fun rememberRailState(screenKey: String): RailState {
|
||||
val context = LocalContext.current
|
||||
// The same file the File Manager keeps its own browse settings in (no androidx.preference here).
|
||||
val prefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val prefs = remember { if (inspecting) null else context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
val choseKey = "rail_${screenKey}_userChose"
|
||||
@@ -130,9 +132,9 @@ fun rememberRailState(screenKey: String): RailState {
|
||||
|
||||
// Keep the landscape choice in saveable state regardless of orientation so portrait→landscape
|
||||
// restores it. In portrait we simply report collapsed+locked and ignore it.
|
||||
var userChose by rememberSaveable(screenKey) { mutableStateOf(prefs.getBoolean(choseKey, false)) }
|
||||
var userChose by rememberSaveable(screenKey) { mutableStateOf((prefs?.getBoolean(choseKey, false) ?: false)) }
|
||||
var landscapeCollapsed by rememberSaveable(screenKey) {
|
||||
mutableStateOf(if (prefs.getBoolean(choseKey, false)) prefs.getBoolean(collapsedKey, false) else false)
|
||||
mutableStateOf(if ((prefs?.getBoolean(choseKey, false) ?: false)) (prefs?.getBoolean(collapsedKey, false) ?: false) else false)
|
||||
}
|
||||
|
||||
if (!isLandscape) {
|
||||
@@ -144,10 +146,7 @@ fun rememberRailState(screenKey: String): RailState {
|
||||
onToggle = {
|
||||
landscapeCollapsed = !landscapeCollapsed
|
||||
userChose = true
|
||||
prefs.edit()
|
||||
.putBoolean(choseKey, true)
|
||||
.putBoolean(collapsedKey, landscapeCollapsed)
|
||||
.apply()
|
||||
prefs?.edit()?.putBoolean(choseKey, true)?.putBoolean(collapsedKey, landscapeCollapsed)?.apply()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -187,6 +188,7 @@ internal fun FileItemRow(
|
||||
) {
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
val context = LocalContext.current
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val isDir = file.isDirectory
|
||||
val isExe = !isDir && file.name.lowercase().let { it.endsWith(".exe") || it.endsWith(".bat") || it.endsWith(".msi") || it.endsWith(".sh") }
|
||||
// Image files show a real thumbnail instead of the generic file icon (handy when picking a
|
||||
@@ -195,7 +197,7 @@ internal fun FileItemRow(
|
||||
|
||||
// For real PE executables, try to pull out the embedded application icon (async, off the main thread).
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
if (!inspecting && !isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
@@ -364,13 +366,15 @@ internal fun FavoritesList(
|
||||
onJump: (File) -> Unit,
|
||||
onUnpin: (File) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
previewFavorites: List<File> = emptyList(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val favorites = remember(favTick) {
|
||||
FavoritesStore.list(context).map(::File).filter { it.exists() }
|
||||
if (inspecting) previewFavorites else FavoritesStore.list(context).map(::File).filter { it.exists() }
|
||||
}
|
||||
val currentAlreadyPinned = remember(favTick, currentDir.absolutePath) {
|
||||
FavoritesStore.isFavorite(context, currentDir.absolutePath)
|
||||
if (inspecting) currentDir in previewFavorites else FavoritesStore.isFavorite(context, currentDir.absolutePath)
|
||||
}
|
||||
|
||||
LazyColumn(modifier = modifier) {
|
||||
@@ -545,10 +549,11 @@ internal fun FileGridTile(
|
||||
onToggleFavorite: () -> Unit = {},
|
||||
onProperties: () -> Unit = {},
|
||||
) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val isDir = file.isDirectory
|
||||
val isImage = !isDir && file.extension.lowercase() in IMAGE_THUMB_EXTS
|
||||
var exeIcon by remember(file.absolutePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
if (!isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
if (!inspecting && !isDir && file.name.lowercase().endsWith(".exe")) {
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
val bmp = withContext(Dispatchers.IO) { PeIconExtractor.extract(file) }
|
||||
if (bmp != null) exeIcon = bmp.asImageBitmap()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import java.io.File
|
||||
|
||||
/** Metadata for design previews. [FileManagerScreen] ignores it outside inspection mode. */
|
||||
class FileManagerPreviewState(val directory: File, val entries: List<File>, val grid: Boolean = true, val favorites: Boolean = false)
|
||||
@@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -109,8 +110,11 @@ fun FileManagerScreen(
|
||||
initialDir: File? = null,
|
||||
pickerTitle: String? = null,
|
||||
onPick: ((File) -> Unit)? = null,
|
||||
preview: FileManagerPreviewState? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val sample = preview.takeIf { inspecting }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Only matching files are shown in pick mode (directories are always shown). Empty = all files.
|
||||
@@ -121,14 +125,15 @@ fun FileManagerScreen(
|
||||
return lowerExts.any { name.endsWith(".$it") }
|
||||
}
|
||||
|
||||
val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
|
||||
val pickPrefs = remember { if (inspecting) null else context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
|
||||
val browsePrefs = pickPrefs
|
||||
val rootDir = remember {
|
||||
// Both modes: honour an explicit caller-supplied start dir (e.g. Log Manager's game-log
|
||||
// folder), else open at the INTERNAL STORAGE ROOT. Selection screens (drive-folder pick,
|
||||
// local component pick, imports) previously defaulted to Download which - combined with the
|
||||
// currentRoot floor below - trapped users in Download with no way up (reported bug).
|
||||
initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
|
||||
if (inspecting) sample?.directory ?: File("/storage/emulated/0")
|
||||
else initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
|
||||
}
|
||||
|
||||
var currentDir by remember { mutableStateOf(rootDir) }
|
||||
@@ -156,7 +161,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
mutableStateOf(vol)
|
||||
}
|
||||
var entries by remember { mutableStateOf(listOf<File>()) }
|
||||
var entries by remember { mutableStateOf(sample?.entries.orEmpty()) }
|
||||
var selectedEntry by remember { mutableStateOf<File?>(null) }
|
||||
var showMenuFor by remember { mutableStateOf<File?>(null) }
|
||||
// Clipboard holds a LIST so one paste can carry a whole selection. Cut/copy semantics are a
|
||||
@@ -177,9 +182,9 @@ fun FileManagerScreen(
|
||||
// Browse controls. Persisted so the list doesn't reset its order every time you open a folder.
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var showSearch by remember { mutableStateOf(false) }
|
||||
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
|
||||
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
|
||||
var showHidden by remember { mutableStateOf(browsePrefs.getBoolean("fmShowHidden", true)) }
|
||||
var sortBy by remember { mutableStateOf(browsePrefs?.getString("fmSortBy", "name") ?: "name") }
|
||||
var sortDesc by remember { mutableStateOf(browsePrefs?.getBoolean("fmSortDesc", false) ?: false) }
|
||||
var showHidden by remember { mutableStateOf(browsePrefs?.getBoolean("fmShowHidden", true) ?: true) }
|
||||
var showSortMenu by remember { mutableStateOf(false) }
|
||||
// View mode: list of cards (default) or a thumbnail grid. Density applies to the list only -
|
||||
// a grid tile has no second line to compact.
|
||||
@@ -187,9 +192,9 @@ fun FileManagerScreen(
|
||||
// choice persists across rotation). Grid is the default - most useful in landscape, and in
|
||||
// portrait GridCells.Adaptive naturally renders fewer columns (~2). Do NOT force portrait to list:
|
||||
// that broke the toggle on-device (tapping it did nothing in portrait).
|
||||
var gridView by remember { mutableStateOf(browsePrefs.getBoolean("fmGridView", true)) }
|
||||
var gridView by remember { mutableStateOf(sample?.grid ?: (browsePrefs?.getBoolean("fmGridView", true) ?: true)) }
|
||||
val showGrid = gridView
|
||||
var compactRows by remember { mutableStateOf(browsePrefs.getBoolean("fmCompactRows", false)) }
|
||||
var compactRows by remember { mutableStateOf(browsePrefs?.getBoolean("fmCompactRows", false) ?: false) }
|
||||
var showNewFolderDialog by remember { mutableStateOf(false) }
|
||||
var renameTarget by remember { mutableStateOf<File?>(null) }
|
||||
// Properties sheet (basic info + Read-only / Hidden toggles) target; null when closed.
|
||||
@@ -203,7 +208,7 @@ fun FileManagerScreen(
|
||||
|
||||
// Favorites view: when on, a dedicated bookmarks list replaces the file list.
|
||||
// favTick is bumped on any add/remove/toggle so the favorites view + per-row star recompute.
|
||||
var showFavorites by remember { mutableStateOf(false) }
|
||||
var showFavorites by remember { mutableStateOf(sample?.favorites == true) }
|
||||
var favTick by remember { mutableIntStateOf(0) }
|
||||
|
||||
// resetScroll: jump to the top of the list (true for navigation; false for in-place reloads
|
||||
@@ -211,7 +216,8 @@ fun FileManagerScreen(
|
||||
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
|
||||
currentDir = dir
|
||||
// Remember the browsed directory so the next pick resumes here.
|
||||
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
|
||||
if (inspecting) return
|
||||
if (pickMode) pickPrefs?.edit()?.putString("lastFilePickerDir", dir.absolutePath)?.apply()
|
||||
scope.launch {
|
||||
val list = withContext(Dispatchers.IO) {
|
||||
dir.listFiles()?.toList()
|
||||
@@ -281,6 +287,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun performDelete(file: File) {
|
||||
if (inspecting) return
|
||||
scope.launch {
|
||||
isOperationRunning = true
|
||||
operationLabel = context.getString(R.string.fm_op_deleting)
|
||||
@@ -302,6 +309,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun performPaste() {
|
||||
if (inspecting) return
|
||||
val sources = clipboardFiles
|
||||
if (sources.isEmpty()) return
|
||||
val dstDir = currentDir
|
||||
@@ -387,6 +395,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun performRename(file: File, newName: String) {
|
||||
if (inspecting) return
|
||||
val target = File(file.parentFile, newName)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
|
||||
@@ -403,6 +412,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
|
||||
fun createFolder(parent: File, name: String) {
|
||||
if (inspecting) return
|
||||
val target = File(parent, name)
|
||||
if (target.exists()) {
|
||||
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
|
||||
@@ -430,9 +440,9 @@ fun FileManagerScreen(
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val drives = remember(storageTick) { StorageRoots.list(context) }
|
||||
val drives = remember(storageTick) { if (inspecting) emptyList() else StorageRoots.list(context) }
|
||||
val linuxRoot = remember(storageTick) {
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
|
||||
if (inspecting) null else com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context).takeIf { File(it, "usr").isDirectory }
|
||||
}
|
||||
|
||||
// ── Dialogs ──
|
||||
@@ -537,6 +547,7 @@ fun FileManagerScreen(
|
||||
pendingBulkDelete = emptyList()
|
||||
selectionMode = false
|
||||
selectedPaths = emptySet()
|
||||
if (inspecting) return@TextButton
|
||||
operationJob = scope.launch {
|
||||
isOperationRunning = true
|
||||
var failed = 0
|
||||
@@ -754,7 +765,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
IconButton(onClick = {
|
||||
gridView = !gridView
|
||||
browsePrefs.edit().putBoolean("fmGridView", gridView).apply()
|
||||
browsePrefs?.edit()?.putBoolean("fmGridView", gridView)?.apply()
|
||||
}) {
|
||||
Icon(
|
||||
if (gridView) Icons.Filled.ViewList else Icons.Filled.GridView,
|
||||
@@ -780,8 +791,8 @@ fun FileManagerScreen(
|
||||
// Tapping the active field flips direction; a different
|
||||
// field switches to it ascending.
|
||||
if (sortBy == key) sortDesc = !sortDesc else { sortBy = key; sortDesc = false }
|
||||
browsePrefs.edit().putString("fmSortBy", sortBy)
|
||||
.putBoolean("fmSortDesc", sortDesc).apply()
|
||||
browsePrefs?.edit()?.putString("fmSortBy", sortBy)
|
||||
?.putBoolean("fmSortDesc", sortDesc)?.apply()
|
||||
showSortMenu = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
},
|
||||
@@ -792,7 +803,7 @@ fun FileManagerScreen(
|
||||
text = { Text(if (compactRows) stringResource(R.string.fm_rows_comfortable) else stringResource(R.string.fm_rows_compact)) },
|
||||
onClick = {
|
||||
compactRows = !compactRows
|
||||
browsePrefs.edit().putBoolean("fmCompactRows", compactRows).apply()
|
||||
browsePrefs?.edit()?.putBoolean("fmCompactRows", compactRows)?.apply()
|
||||
showSortMenu = false
|
||||
},
|
||||
)
|
||||
@@ -800,7 +811,7 @@ fun FileManagerScreen(
|
||||
text = { Text(if (showHidden) stringResource(R.string.fm_hide_hidden) else stringResource(R.string.fm_show_hidden)) },
|
||||
onClick = {
|
||||
showHidden = !showHidden
|
||||
browsePrefs.edit().putBoolean("fmShowHidden", showHidden).apply()
|
||||
browsePrefs?.edit()?.putBoolean("fmShowHidden", showHidden)?.apply()
|
||||
showSortMenu = false
|
||||
loadDirectory(currentDir, resetScroll = false)
|
||||
},
|
||||
@@ -1016,15 +1027,15 @@ fun FileManagerScreen(
|
||||
linuxRoot?.let { add(locItem(stringResource(R.string.fm_drive_linux), Icons.Filled.Terminal, it)) }
|
||||
}
|
||||
val quickItems = buildList {
|
||||
File("/storage/emulated/0/Download").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
|
||||
File("/storage/emulated/0/Download").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_downloads), Icons.Filled.Download, it, stringResource(R.string.fm_rail_downloads_short))) }
|
||||
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
(if (inspecting) "" else SessionPrefs.romsDir(context)).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
|
||||
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
|
||||
// The app's own logs folder: private, which this file manager (the app itself) can read.
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
|
||||
File("/storage/emulated/0/Pictures").takeIf { !inspecting && it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
|
||||
}
|
||||
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
val favItems = remember(favTick) { if (inspecting) sample?.entries.orEmpty().filter { it.isDirectory } else FavoritesStore.list(context).map(::File).filter { it.exists() } }
|
||||
.map { d -> RailItem(d.name, Icons.Filled.Star, false) { showFavorites = false; openDrive(d) } }
|
||||
val locationSections = buildList {
|
||||
add(RailSection(stringResource(R.string.fm_rail_storage), storageItems))
|
||||
@@ -1043,8 +1054,9 @@ fun FileManagerScreen(
|
||||
FavoritesList(
|
||||
currentDir = currentDir,
|
||||
favTick = favTick,
|
||||
previewFavorites = sample?.entries.orEmpty().filter { it.isDirectory },
|
||||
onPinCurrent = {
|
||||
FavoritesStore.add(context, currentDir.absolutePath)
|
||||
if (!inspecting) FavoritesStore.add(context, currentDir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_added, currentDir.name), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
@@ -1053,7 +1065,7 @@ fun FileManagerScreen(
|
||||
openDrive(dir)
|
||||
},
|
||||
onUnpin = { dir ->
|
||||
FavoritesStore.remove(context, dir.absolutePath)
|
||||
if (!inspecting) FavoritesStore.remove(context, dir.absolutePath)
|
||||
favTick++
|
||||
Toast.makeText(context, context.getString(R.string.fm_favorite_removed, dir.name), Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
@@ -1080,7 +1092,7 @@ fun FileManagerScreen(
|
||||
}
|
||||
items(shownEntries, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
FavoritesStore.isFavorite(context, file.absolutePath)
|
||||
(!inspecting && FavoritesStore.isFavorite(context, file.absolutePath))
|
||||
}
|
||||
FileGridTile(
|
||||
file = file,
|
||||
@@ -1108,7 +1120,7 @@ fun FileManagerScreen(
|
||||
if (file.isDirectory) loadDirectory(file)
|
||||
else if (pickMode) {
|
||||
if (matchesPickExt(file)) {
|
||||
pickPrefs.edit().putString("lastFilePickerDir", currentDir.absolutePath).apply()
|
||||
pickPrefs?.edit()?.putString("lastFilePickerDir", currentDir.absolutePath)?.apply()
|
||||
onPick?.invoke(file)
|
||||
}
|
||||
}
|
||||
@@ -1127,7 +1139,7 @@ fun FileManagerScreen(
|
||||
onCut = { clipboardFiles = listOf(file); isCutOperation = true; showMenuFor = null },
|
||||
onDelete = { selectedEntry = file; showMenuFor = null },
|
||||
onToggleFavorite = {
|
||||
val nowFav = FavoritesStore.toggle(context, file.absolutePath)
|
||||
val nowFav = if (inspecting) false else FavoritesStore.toggle(context, file.absolutePath)
|
||||
favTick++
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
@@ -1159,7 +1171,7 @@ fun FileManagerScreen(
|
||||
val shown = shownEntries
|
||||
items(shown, key = { it.absolutePath }) { file ->
|
||||
val isFav = remember(file.absolutePath, favTick) {
|
||||
FavoritesStore.isFavorite(context, file.absolutePath)
|
||||
(!inspecting && FavoritesStore.isFavorite(context, file.absolutePath))
|
||||
}
|
||||
FileItemRow(
|
||||
file = file,
|
||||
@@ -1189,7 +1201,7 @@ fun FileManagerScreen(
|
||||
if (file.isDirectory) loadDirectory(file)
|
||||
else if (pickMode) {
|
||||
if (matchesPickExt(file)) {
|
||||
pickPrefs.edit().putString("lastFilePickerDir", currentDir.absolutePath).apply()
|
||||
pickPrefs?.edit()?.putString("lastFilePickerDir", currentDir.absolutePath)?.apply()
|
||||
onPick?.invoke(file)
|
||||
}
|
||||
}
|
||||
@@ -1208,7 +1220,7 @@ fun FileManagerScreen(
|
||||
onRename = { renameTarget = file; showMenuFor = null },
|
||||
isFavorite = isFav,
|
||||
onToggleFavorite = {
|
||||
val nowFav = FavoritesStore.toggle(context, file.absolutePath)
|
||||
val nowFav = if (inspecting) false else FavoritesStore.toggle(context, file.absolutePath)
|
||||
favTick++
|
||||
showMenuFor = null
|
||||
Toast.makeText(
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -58,11 +59,13 @@ internal fun FilePropertiesDialog(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) }
|
||||
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(null) }
|
||||
var attrs by remember(file.absolutePath) { mutableStateOf<FileAttrState?>(if (inspecting) FileAttrState(false, false, false) else null) }
|
||||
// Guards against a second toggle landing while the first is still being applied off-thread.
|
||||
var busy by remember(file.absolutePath) { mutableStateOf(false) }
|
||||
LaunchedEffect(file.absolutePath) {
|
||||
if (inspecting) return@LaunchedEffect
|
||||
attrs = withContext(Dispatchers.IO) { readFileAttrs(file) }
|
||||
}
|
||||
|
||||
@@ -101,7 +104,11 @@ internal fun FilePropertiesDialog(
|
||||
description = stringResource(R.string.fm_read_only_hint),
|
||||
checked = state?.readOnly == true,
|
||||
enabled = state != null && !busy,
|
||||
onToggle = { want ->
|
||||
onToggle = toggle@{ want ->
|
||||
if (inspecting) {
|
||||
attrs = attrs?.copy(readOnly = want)
|
||||
return@toggle
|
||||
}
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setReadOnly(file, want) }
|
||||
@@ -125,7 +132,11 @@ internal fun FilePropertiesDialog(
|
||||
else stringResource(R.string.fm_hidden_hint),
|
||||
checked = state?.hidden == true,
|
||||
enabled = state != null && hiddenSupported && !busy,
|
||||
onToggle = { want ->
|
||||
onToggle = toggle@{ want ->
|
||||
if (inspecting) {
|
||||
attrs = attrs?.copy(hidden = want)
|
||||
return@toggle
|
||||
}
|
||||
busy = true
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { setHidden(file, want) }
|
||||
|
||||
@@ -90,6 +90,7 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
@@ -302,7 +303,8 @@ internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (St
|
||||
/** Open from the first frame; [onDismiss] once the closing animation has run. */
|
||||
@Composable
|
||||
internal fun rememberShown(onDismiss: () -> Unit): MutableTransitionState<Boolean> {
|
||||
val shown = remember { MutableTransitionState(false).apply { targetState = true } }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val shown = remember { MutableTransitionState(inspecting).apply { targetState = true } }
|
||||
LaunchedEffect(shown.currentState, shown.isIdle) { if (shown.isIdle && !shown.currentState && !shown.targetState) onDismiss() }
|
||||
return shown
|
||||
}
|
||||
@@ -507,7 +509,9 @@ private fun IconActions(onChoose: () -> Unit, clear: Pair<Int, () -> Unit>?) {
|
||||
/** Icons found in [repo], looked up off the main thread once the name has settled; [selected] is ringed. */
|
||||
@Composable
|
||||
private fun IconSuggestions(repo: String, selected: String?, onPick: (String) -> Unit) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val found by produceState<List<String>?>(null, repo) {
|
||||
if (inspecting) { value = emptyList(); return@produceState }
|
||||
value = null
|
||||
delay(500)
|
||||
value = withContext(Dispatchers.IO) { runCatching { UserApps.githubIcons(repo) }.getOrDefault(emptyList()) }
|
||||
@@ -570,8 +574,10 @@ private fun FileRow(path: String?, onChoose: () -> Unit) {
|
||||
/** Whether the script's folder will be linked or copied in, worked out off the main thread. */
|
||||
@Composable
|
||||
private fun FolderNote(scriptPath: String) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
var plan by remember(scriptPath) { mutableStateOf<UserApps.ScriptPlan?>(null) }
|
||||
LaunchedEffect(scriptPath) {
|
||||
if (inspecting) return@LaunchedEffect
|
||||
plan = withContext(Dispatchers.IO) { runCatching { UserApps.planScript(File(scriptPath)) }.getOrNull() }
|
||||
}
|
||||
val p = plan
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
@@ -69,7 +70,8 @@ internal class Tile(
|
||||
@Composable
|
||||
internal fun Poster(art: File?, name: String, modifier: Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val t = remember { Animatable(0f) }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val t = remember { Animatable(if (inspecting) 1f else 0f) }
|
||||
LaunchedEffect(Unit) { t.animateTo(1f, Motion.sp(0.6f, Spring.StiffnessLow)) }
|
||||
Box(
|
||||
modifier = modifier.padding(start = 16.dp).aspectRatio(2f / 3f)
|
||||
|
||||
@@ -91,6 +91,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.HomeApp
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.runtime.UserApps
|
||||
@@ -264,7 +265,8 @@ private fun Content(
|
||||
val installed = s.emulators.filter { it.installed }
|
||||
val available = s.emulators.filter { !it.installed }
|
||||
val ctx = LocalContext.current
|
||||
LaunchedEffect(Unit) { UserAppsState.refresh(ctx) }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
LaunchedEffect(Unit) { if (!inspecting) UserAppsState.refresh(ctx) }
|
||||
val apps = UserAppsState.items
|
||||
var adding by rememberSaveable { mutableStateOf(false) }
|
||||
Rise(0) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.ui
|
||||
|
||||
import java.io.File
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -259,7 +260,8 @@ internal fun artBrush(h: Float) = Brush.linearGradient(listOf(tint(h), tint((h +
|
||||
|
||||
@Composable
|
||||
internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
val state = remember { MutableTransitionState(false) }.apply { targetState = true }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val state = remember { MutableTransitionState(inspecting) }.apply { targetState = true }
|
||||
AnimatedVisibility(
|
||||
visibleState = state, modifier = modifier,
|
||||
enter = fadeIn(Motion.tw(450, i * 60)) + slideInVertically(Motion.tw(450, i * 60)) { it / 3 },
|
||||
@@ -270,6 +272,7 @@ internal fun Rise(i: Int, modifier: Modifier = Modifier, content: @Composable ()
|
||||
|
||||
@Composable
|
||||
private fun Modifier.staggerIn(i: Int): Modifier {
|
||||
if (LocalInspectionMode.current) return this
|
||||
val t = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) { t.animateTo(1f, Motion.tw(360, i * 30)) }
|
||||
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 10.dp.toPx() }
|
||||
@@ -378,15 +381,16 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) {
|
||||
fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null, initialSelection: String = if (s.shortcutPicker) "games" else "steam") {
|
||||
val frontFocus = remember { FrontFocus() }
|
||||
CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus) }
|
||||
CompositionLocalProvider(LocalFrontFocus provides frontFocus) { FrontEndScreenBody(s, a, page, frontFocus, initialSelection) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
|
||||
var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") }
|
||||
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus, initialSelection: String) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
var selected by rememberSaveable { mutableStateOf(initialSelection) }
|
||||
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
|
||||
LaunchedEffect(s.navigate) {
|
||||
val target = s.navigate ?: return@LaunchedEffect
|
||||
@@ -435,7 +439,7 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
|
||||
LaunchedEffect(s.steamGames.isEmpty()) {
|
||||
if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games"
|
||||
}
|
||||
remember { Motion.refresh(ctx); true }
|
||||
remember { if (!inspecting) Motion.refresh(ctx); true }
|
||||
|
||||
val railSelection = when {
|
||||
s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup"
|
||||
|
||||
@@ -76,6 +76,7 @@ import com.droiddeck.launcher.core.DeviceSupport
|
||||
import com.droiddeck.launcher.core.PhantomProcessLimit
|
||||
import com.droiddeck.launcher.core.PhantomProcessStatus
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
@@ -130,6 +131,7 @@ internal fun SetupPanel(
|
||||
a: FrontEndActions,
|
||||
onOpenDeveloperOptions: () -> Unit,
|
||||
onRequestWirelessAdb: (Boolean) -> Unit,
|
||||
initialTab: Int = 0,
|
||||
) {
|
||||
val host = rememberMenuHost()
|
||||
val ctx = LocalContext.current
|
||||
@@ -158,9 +160,10 @@ internal fun SetupPanel(
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
// Tested hardware passes; an Adreno below it (a 610, say) warns rather than claiming support.
|
||||
val gpu = remember { com.droiddeck.launcher.gpu.GpuInfo.detect() }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val gpu = remember { if (inspecting) com.droiddeck.launcher.gpu.GpuInfo("Adreno 740", 740, com.droiddeck.launcher.gpu.GpuInfo.Family.A7XX, "Snapdragon 8 Gen 2", false) else com.droiddeck.launcher.gpu.GpuInfo.detect() }
|
||||
val gpuOk = gpu.support == com.droiddeck.launcher.gpu.GpuInfo.Support.TESTED
|
||||
val gpuName = remember { DeviceSupport.gpuName(ctx) }
|
||||
val gpuName = remember { if (inspecting) gpu.name else DeviceSupport.gpuName(ctx) }
|
||||
val limitBlocks = PhantomProcessLimit.blocksSteam(s.phantomProcessStatus)
|
||||
val signedIn = s.offlineAccount != null
|
||||
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -169,7 +172,7 @@ internal fun SetupPanel(
|
||||
// Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
|
||||
// and credits are on the Updates page.
|
||||
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_session), stringResource(R.string.setup_tab_launcher))
|
||||
var tab by rememberSaveable { mutableStateOf(0) }
|
||||
var tab by rememberSaveable { mutableStateOf(initialTab.coerceIn(0, tabs.lastIndex)) }
|
||||
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
|
||||
var tabTurned by remember { mutableStateOf(false) }
|
||||
val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true }
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.GameEnvironmentOptions
|
||||
@@ -81,12 +82,13 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
internal fun GameEnvironmentEditor(byPad: Boolean, initialConfig: GameEnvironment.Config = GameEnvironment.Config(), onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(if (inspecting) initialConfig else null) }
|
||||
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var otherId by remember { mutableStateOf<String?>(null) }
|
||||
@@ -94,12 +96,13 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
val forceSsbs = SessionPrefs.forceSsbs(context)
|
||||
val preset = if (inspecting) com.droiddeck.launcher.core.FexPreset.all.first().id else SessionPrefs.fexPreset(context)
|
||||
val forceSsbs = !inspecting && SessionPrefs.forceSsbs(context)
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
var editByPad by remember { mutableStateOf(false) }
|
||||
val dialogInput = LocalInputModeManager.current
|
||||
LaunchedEffect(Unit) {
|
||||
if (inspecting) return@LaunchedEffect
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
|
||||
@@ -108,6 +111,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
|
||||
.onFailure { error = true }
|
||||
}
|
||||
fun save(next: GameEnvironment.Config) {
|
||||
if (inspecting) { config = next; return }
|
||||
busy = true
|
||||
coroutine.launch {
|
||||
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
|
||||
|
||||
@@ -165,12 +165,12 @@ private enum class ModeSettingsTab(val label: Int) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions, initialTab: Int = 0) {
|
||||
val steam = s.mode == SessionService.MODE_STEAM
|
||||
val host = rememberMenuHost()
|
||||
var confirmDeckyRemoval by remember { mutableStateOf(false) }
|
||||
var explainWifiDiscovery by remember { mutableStateOf(false) }
|
||||
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(0) }
|
||||
var tabIndex by androidx.compose.runtime.saveable.rememberSaveable(s.mode) { mutableStateOf(initialTab.coerceIn(0, if (steam) ModeSettingsTab.entries.lastIndex else 2)) }
|
||||
val tabs = if (steam) ModeSettingsTab.entries else listOf(
|
||||
ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.content.Context
|
||||
@@ -53,8 +54,10 @@ fun PhantomProcessGatePage(
|
||||
) {
|
||||
BackHandler(onBack = onDismiss)
|
||||
val context = LocalContext.current
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val stage by WirelessAdbPairingService.stage.collectAsState()
|
||||
val environment by produceState(GateEnvironment.read(context)) {
|
||||
val environment by produceState(if (inspecting) GateEnvironment(true, true) else GateEnvironment.read(context)) {
|
||||
if (inspecting) return@produceState
|
||||
while (true) {
|
||||
value = GateEnvironment.read(context)
|
||||
kotlinx.coroutines.delay(2_000)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -148,8 +149,10 @@ fun LoadingOverlay(
|
||||
val active = reached.coerceAtMost(stages.lastIndex)
|
||||
val detail = if (percent >= 0 || readable) step.replaceFirstChar { it.uppercase() } else stringResource(stages[active].detail)
|
||||
val context = LocalContext.current
|
||||
val inspecting = LocalInspectionMode.current
|
||||
// The Steam tab's wall behind it, dimmed and slowed: starting Steam reads as the same place settling in.
|
||||
val games by produceState(emptyList<Library.SteamGame>()) {
|
||||
if (inspecting) return@produceState
|
||||
value = withContext(Dispatchers.IO) { runCatching { Library.steamGames(context).sortedByDescending { it.lastPlayed } }.getOrDefault(emptyList()) }
|
||||
}
|
||||
Box(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -269,7 +270,8 @@ fun AnchoredMenu(
|
||||
compact: Boolean = false, minWidth: androidx.compose.ui.unit.Dp = 120.dp,
|
||||
content: @Composable ColumnScope.(FocusRequester) -> Unit,
|
||||
) {
|
||||
val state = remember { MutableTransitionState(false) }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val state = remember { MutableTransitionState(inspecting && open) }
|
||||
state.targetState = open
|
||||
if (!state.currentState && !state.targetState && state.isIdle) return
|
||||
val firstItemFocus = remember { FocusRequester() }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.activity.compose.BackHandler
|
||||
@@ -69,8 +70,10 @@ import com.droiddeck.launcher.store.StoreState
|
||||
private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed)
|
||||
|
||||
@Composable
|
||||
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, previewSections: Map<String, List<FlathubApi.AppSummary>>? = null) {
|
||||
val ctx = LocalContext.current
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val sections = previewSections.takeIf { inspecting } ?: StoreState.sections
|
||||
val narrow = LocalNarrowPane.current
|
||||
val padH = if (narrow) 16.dp else 22.dp
|
||||
val padV = if (narrow) 12.dp else 18.dp
|
||||
@@ -78,8 +81,8 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
var openApp by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var category by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(s.ready) { StoreState.refresh(ctx) }
|
||||
LaunchedEffect(Unit) { StoreState.loadSections() }
|
||||
LaunchedEffect(s.ready) { if (!inspecting) StoreState.refresh(ctx) }
|
||||
LaunchedEffect(Unit) { if (!inspecting) StoreState.loadSections() }
|
||||
// A pad's focus sits on something the page is about to replace - the tile that opens an app,
|
||||
// a tab's contents - and would be lost with it. Each move says where focus goes next.
|
||||
val ff = LocalFrontFocus.current
|
||||
@@ -132,7 +135,7 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
}
|
||||
BusyBar()
|
||||
when (tab) {
|
||||
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) })
|
||||
0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; if (!inspecting) StoreState.search("", it) }, sections = sections)
|
||||
1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it })
|
||||
else -> Installed(a, onOpen = { openApp = it })
|
||||
}
|
||||
@@ -142,6 +145,7 @@ internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier)
|
||||
/** Flatpak is not in the runtime yet: one button puts it there. */
|
||||
@Composable
|
||||
private fun SetupCard() {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -156,7 +160,7 @@ private fun SetupCard() {
|
||||
fontSize = 14.sp, color = colors.onSurfaceVariant,
|
||||
)
|
||||
Actions {
|
||||
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { StoreState.setup(ctx) }
|
||||
PrimaryButton(if (busy) stringResource(R.string.store_setting_up) else stringResource(R.string.store_setup_title), enabled = StoreState.busy == null, main = true) { if (!inspecting) StoreState.setup(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,20 +185,21 @@ private fun BusyBar() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
|
||||
private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit, sections: Map<String, List<FlathubApi.AppSummary>>) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
Rise(3) { SectionTitle(stringResource(R.string.store_categories), null) }
|
||||
Rise(3) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 4.dp)) {
|
||||
FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = false) { onCategory(c.id) } }
|
||||
}
|
||||
}
|
||||
if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) {
|
||||
if (StoreState.sectionsFailed && sections.values.all { it.isEmpty() }) {
|
||||
Rise(4) { Note(stringResource(R.string.store_unreachable)) }
|
||||
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } }
|
||||
Actions { SecondaryButton(stringResource(R.string.store_try_again)) { if (!inspecting) StoreState.loadSections(force = true) } }
|
||||
return
|
||||
}
|
||||
StoreState.SECTIONS.forEachIndexed { i, (key, title) ->
|
||||
val apps = StoreState.sections[key]
|
||||
val apps = sections[key]
|
||||
Rise(4 + i) { SectionTitle(stringResource(title), apps?.let { if (it.isEmpty()) null else it.size.toString() } ?: stringResource(R.string.store_section_loading)) }
|
||||
if (apps != null && apps.isNotEmpty()) Rise(4 + i) { AppGrid(apps.take(12), first = i == 0, onOpen = onOpen) }
|
||||
}
|
||||
@@ -202,12 +207,13 @@ private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val view = androidx.compose.ui.platform.LocalView.current
|
||||
val run = {
|
||||
// The field is an Android EditText; Done leaves its keyboard over the results otherwise.
|
||||
(view.context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
|
||||
?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
StoreState.search(query.trim(), category)
|
||||
if (!inspecting) StoreState.search(query.trim(), category)
|
||||
}
|
||||
Rise(3) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
@@ -220,9 +226,9 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
|
||||
}
|
||||
Rise(4) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 4.dp)) {
|
||||
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); StoreState.search(query.trim(), null) }
|
||||
PillButton(stringResource(R.string.store_all), selected = category == null) { onCategory(null); if (!inspecting) StoreState.search(query.trim(), null) }
|
||||
FlathubApi.categories.forEach { c ->
|
||||
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) }
|
||||
PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); if (!inspecting) StoreState.search(query.trim(), c.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +246,7 @@ private fun Search(query: String, category: String?, onQuery: (String) -> Unit,
|
||||
|
||||
@Composable
|
||||
private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -247,10 +254,10 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
|
||||
Rise(3) {
|
||||
Actions {
|
||||
SecondaryButton(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = StoreState.ready && !StoreState.checkingUpdates && StoreState.busy == null) {
|
||||
StoreState.checkUpdates(ctx)
|
||||
if (!inspecting) StoreState.checkUpdates(ctx)
|
||||
}
|
||||
if (StoreState.updates.isNotEmpty()) PrimaryButton(stringResource(R.string.store_update_all, StoreState.updates.size), enabled = StoreState.busy == null) {
|
||||
StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
|
||||
if (!inspecting) StoreState.update(ctx, null, ctx.getString(R.string.store_updates))
|
||||
} else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true)
|
||||
}
|
||||
}
|
||||
@@ -276,7 +283,7 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) {
|
||||
)
|
||||
}
|
||||
PrimaryButton(stringResource(R.string.store_open), compact = true, enabled = !s_busyFor(app.id)) { a.onFlatpakApp(app.id, app.name) }
|
||||
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { StoreState.update(ctx, app.id, app.name) }
|
||||
if (app.id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), compact = true, enabled = StoreState.busy == null) { if (!inspecting) StoreState.update(ctx, app.id, app.name) }
|
||||
SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) }
|
||||
}
|
||||
}
|
||||
@@ -292,7 +299,8 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
val ctx = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val narrow = LocalNarrowPane.current
|
||||
LaunchedEffect(id) { StoreState.loadDetails(id) }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
LaunchedEffect(id) { if (!inspecting) StoreState.loadDetails(id) }
|
||||
val d = StoreState.details[id]
|
||||
val local = StoreState.installed.firstOrNull { it.id == id }
|
||||
val name = d?.name ?: local?.name ?: id
|
||||
@@ -317,15 +325,15 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack:
|
||||
Actions {
|
||||
if (installed) {
|
||||
PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) }
|
||||
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { StoreState.update(ctx, id, name) }
|
||||
if (id in StoreState.updates) SecondaryButton(stringResource(R.string.common_update), enabled = StoreState.busy == null) { if (!inspecting) StoreState.update(ctx, id, name) }
|
||||
SecondaryButton(if (busyHere) stringResource(R.string.common_working) else if (confirmRemove) stringResource(R.string.store_press_again) else stringResource(R.string.store_remove), enabled = StoreState.busy == null) {
|
||||
if (confirmRemove) { confirmRemove = false; StoreState.uninstall(ctx, id, name) } else confirmRemove = true
|
||||
if (confirmRemove) { confirmRemove = false; if (!inspecting) StoreState.uninstall(ctx, id, name) } else confirmRemove = true
|
||||
}
|
||||
} else {
|
||||
PrimaryButton(
|
||||
if (busyHere) stringResource(R.string.store_installing) else stringResource(R.string.store_install), main = true,
|
||||
enabled = StoreState.ready && StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches),
|
||||
) { StoreState.install(ctx, id, name) }
|
||||
) { if (!inspecting) StoreState.install(ctx, id, name) }
|
||||
if (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false)
|
||||
}
|
||||
d?.version?.let { ActionChip("v$it", ok = false) }
|
||||
|
||||
@@ -92,10 +92,10 @@ class UpdatesActions(
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
|
||||
internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, installed: AppUpdates.Installed? = null) {
|
||||
val u = s.updates
|
||||
val ua = a.updates
|
||||
val me = remember { AppUpdates.installed() }
|
||||
val me = remember(installed) { installed ?: AppUpdates.installed() }
|
||||
val colors = MaterialTheme.colorScheme
|
||||
Column(modifier = modifier) {
|
||||
PageHeader(stringResource(R.string.upd_title)) {
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.DependencyDetector
|
||||
import com.droiddeck.launcher.frontend.PrefixInstalledDetector
|
||||
@@ -68,10 +69,12 @@ internal fun WinComponentsDialog(
|
||||
appKey: String, gameName: String, gameDir: File?, byPad: Boolean,
|
||||
/** compatdata/<id>: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */
|
||||
compat: File? = null, steamAppId: Int? = null,
|
||||
previewComponents: List<WinComponents.Component> = emptyList(),
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val shown = rememberShown(onClose)
|
||||
val close = { shown.targetState = false }
|
||||
// null while the catalog loads; empty when it could not be had.
|
||||
@@ -81,7 +84,7 @@ internal fun WinComponentsDialog(
|
||||
var installed by remember { mutableStateOf(emptySet<String>()) }
|
||||
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
|
||||
var present by remember { mutableStateOf(emptySet<String>()) }
|
||||
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
|
||||
var picks by remember { mutableStateOf(if (inspecting) emptyList() else WinComponents.picks(context, appKey)) }
|
||||
// The row being worked on: its id, the latest progress (a dependency's while that installs
|
||||
// first), and whether it is turning off rather than on. The row greys out and shows the bar
|
||||
// the moment the switch is pressed, and only that row; the rest of the page stays as it is.
|
||||
@@ -104,6 +107,7 @@ internal fun WinComponentsDialog(
|
||||
var switchFocused by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (inspecting) { catalog = previewComponents.associateBy { it.name }; return@LaunchedEffect }
|
||||
val (entries, found) = withContext(Dispatchers.IO) {
|
||||
// The folder's own installers first, then what Steam installs with the game.
|
||||
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
|
||||
@@ -118,9 +122,10 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
fun setPicks(next: List<String>) {
|
||||
picks = next
|
||||
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
if (!inspecting) coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
}
|
||||
fun toggle(id: String, on: Boolean) {
|
||||
if (inspecting) { picks = if (on) picks + id else picks - id; return }
|
||||
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
|
||||
// then is ignored here; the busy row is greyed instead.
|
||||
if (busyId != null) return
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import com.droiddeck.launcher.R
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import android.text.Editable
|
||||
@@ -61,7 +62,8 @@ fun WirelessAdbFixPage(
|
||||
onApply: (String, Int, Boolean, (String?) -> Unit) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val savedAddress = remember { WirelessAdbFix.savedConnectionAddress(context) }
|
||||
val inspecting = LocalInspectionMode.current
|
||||
val savedAddress = remember { if (inspecting) null else WirelessAdbFix.savedConnectionAddress(context) }
|
||||
var step by rememberSaveable { mutableStateOf(if (savedAddress == null) 0 else 1) }
|
||||
var pairingAddress by rememberSaveable { mutableStateOf("") }
|
||||
var connectionAddress by rememberSaveable { mutableStateOf(savedAddress.orEmpty()) }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.os.Looper
|
||||
import android.view.inspector.WindowInspector
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import com.droiddeck.launcher.files.filePreviewCases
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner.Parameters
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
|
||||
/** Render the actual preview entry points, including dialogs and their inspection-only paths. */
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-land-mdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
internal class PreviewRenderTest(private val case: PreviewCase) {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun rendersContentWithoutDeviceOrRuntime() = render("handheld")
|
||||
|
||||
@Test @Config(qualifiers = "w480dp-h360dp-land-mdpi")
|
||||
fun rendersCompactLayout() = render("compact")
|
||||
|
||||
@Test @Config(qualifiers = "w400dp-h700dp-port-mdpi")
|
||||
fun rendersPortraitLayout() = render("portrait")
|
||||
|
||||
private fun render(size: String) {
|
||||
if (case.name == "CustomResolution") {
|
||||
// Compose 1.6's AlertDialog + weighted text fields never report idle to Espresso here.
|
||||
// Render this dialog with bounded native frames instead of changing production UI.
|
||||
val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
activity.get().setContent { case.content() }
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1000))
|
||||
saveWindow(size)
|
||||
} finally {
|
||||
activity.pause().stop().destroy()
|
||||
}
|
||||
return
|
||||
}
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent { case.content() }
|
||||
compose.mainClock.advanceTimeBy(1000)
|
||||
compose.waitForIdle()
|
||||
compose.runOnIdle { saveWindow(size) }
|
||||
}
|
||||
|
||||
private fun saveWindow(size: String) {
|
||||
// Dialogs have their own root; capture their content rather than the empty host.
|
||||
val view = WindowInspector.getGlobalWindowViews().last()
|
||||
assertTrue("${case.name}: empty bounds", view.width > 0 && view.height > 0)
|
||||
val image = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||
view.draw(Canvas(image))
|
||||
val pixels = IntArray(image.width * image.height)
|
||||
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
|
||||
assertTrue("${case.name}: blank preview", pixels.any { it != pixels.first() })
|
||||
val output = File("build/preview-renders/$size/${case.name}.png")
|
||||
output.parentFile?.mkdirs()
|
||||
output.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
image.recycle()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic @Parameters(name = "{0}")
|
||||
fun previews(): List<Array<Any>> = (screenPreviewCases + filePreviewCases).map { arrayOf(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Compose previews
|
||||
|
||||
Select the **debug** build variant in Android Studio, then open a preview file under
|
||||
`app/src/debug/java/com/droiddeck/launcher/` and choose **Split** or **Design**.
|
||||
Build the project after syncing Gradle. Previews call the production composables;
|
||||
there is no separate mock UI to keep in sync.
|
||||
|
||||
| File | Coverage |
|
||||
| --- | --- |
|
||||
| `ui/LauncherPreviews.kt` | Steam home, runtime missing/downloading, populated/empty library, game details, desktop, emulator, Android apps, Store, Updates, alternate theme |
|
||||
| `ui/SettingsPreviews.kt` | All four Setup tabs, all six Steam settings tabs, desktop settings, controller/mapping, performance, shared settings widgets |
|
||||
| `ui/RuntimePreviews.kt` | Proton installed/empty/downloading, components/loading, GPU drivers and driver downloads |
|
||||
| `ui/SessionPreviews.kt` | Every session drawer page, loading/starting/error screens, PC keyboard, HUD, cursor |
|
||||
| `ui/DialogPreviews.kt` | Add/edit app, confirmation, custom resolution, ROM folder, environment variables, Windows components, display chooser, wireless ADB, process-limit help |
|
||||
| `files/FilePreviews.kt` | File manager grid/list/empty/favorites, file/folder pickers, properties |
|
||||
|
||||
`@DroidDeckPreviews` shows handheld landscape (960×540 dp), compact landscape
|
||||
(480×360 dp), portrait (400×700 dp), and larger text. The Paper theme has a separate
|
||||
launcher preview. Dimensions are Android layout units, not physical display pixels.
|
||||
|
||||
Edit the real UI components to adjust layouts. Edit `PreviewData.kt` for sample
|
||||
screen state and callbacks, and `FilePreviews.kt` for file metadata. Add a preview
|
||||
for each new screen or materially different state. Add its entry to
|
||||
`PreviewCases.kt` or `filePreviewCases` so it is covered by the render check.
|
||||
|
||||
Preview fixtures and tooling are debug-only. `PreviewHost` supplies the production
|
||||
theme, inspection mode, and inert navigation/file-picker owners. Inspection paths
|
||||
skip automatic runtime, directory, device, and network operations and settle
|
||||
entrance animations so content is visible immediately. File manager mutation
|
||||
commands are disabled in inspection mode. Previews are for layout and local UI
|
||||
interaction; they do not validate installation, pairing, launching, or controller
|
||||
input latency.
|
||||
|
||||
The native game surface, Termux terminal, on-screen gamepad, and second-screen
|
||||
View controls are outside this Compose preview set. Their surrounding Compose
|
||||
menus are covered. These components still need device validation.
|
||||
|
||||
Run the preview render smoke checks:
|
||||
|
||||
```sh
|
||||
./gradlew app:testDebugUnitTest --tests '*PreviewRenderTest'
|
||||
```
|
||||
|
||||
The checks use Robolectric native graphics with an ordinary `Application`, render
|
||||
the same 68 preview entry points at handheld, compact, and portrait sizes, reject
|
||||
empty/solid-color output, and write PNGs to
|
||||
`app/build/preview-renders/`. This catches composition/render errors without starting
|
||||
the Linux runtime. Android Studio uses Layoutlib, so the smoke check complements
|
||||
Studio preview validation rather than replacing it.
|
||||
Reference in new issue
Block a user