From fc026263e4875ac95b73a2b152c70f5a7a11cd32 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:05:21 -0400 Subject: [PATCH] feat: big ass redesign: Carry the stores' motion and layout into Steam settings, Components, Setup, Flathub and Desktop (#548) --- .../com/droiddeck/launcher/MainActivity.kt | 126 +- .../launcher/runtime/DesktopCatalog.kt | 3 + .../launcher/runtime/DesktopRemoval.kt | 8 + .../launcher/session/SessionPrefs.kt | 14 - .../droiddeck/launcher/store/StoreState.kt | 3 + .../droiddeck/launcher/ui/ComponentsPage.kt | 1032 +++++++++++++---- .../com/droiddeck/launcher/ui/DesktopPage.kt | 455 ++++++++ .../com/droiddeck/launcher/ui/FrontEndArt.kt | 13 + .../droiddeck/launcher/ui/FrontEndContent.kt | 354 +++--- .../com/droiddeck/launcher/ui/FrontEndRail.kt | 59 +- .../droiddeck/launcher/ui/FrontEndScreen.kt | 90 +- .../droiddeck/launcher/ui/FrontEndSetup.kt | 324 +++--- .../droiddeck/launcher/ui/GpuDriversPanel.kt | 271 +---- .../java/com/droiddeck/launcher/ui/Hop.kt | 174 +++ .../com/droiddeck/launcher/ui/LiftSheet.kt | 805 +++++++++++++ .../launcher/ui/ModeSettingsDialog.kt | 587 +++++----- .../com/droiddeck/launcher/ui/PageFlood.kt | 46 +- .../com/droiddeck/launcher/ui/ProtonPage.kt | 88 -- .../droiddeck/launcher/ui/SettingsWidgets.kt | 3 + .../com/droiddeck/launcher/ui/SharedMotion.kt | 237 ++++ .../com/droiddeck/launcher/ui/SheetRows.kt | 63 + .../com/droiddeck/launcher/ui/SteamHome.kt | 113 +- .../java/com/droiddeck/launcher/ui/StepOut.kt | 7 +- .../java/com/droiddeck/launcher/ui/Steps.kt | 55 + .../com/droiddeck/launcher/ui/StoreChips.kt | 8 + .../com/droiddeck/launcher/ui/StorePage.kt | 821 +++++++++---- .../droiddeck/launcher/ui/StoresDownloads.kt | 56 +- .../com/droiddeck/launcher/ui/StoresMotion.kt | 64 +- .../com/droiddeck/launcher/ui/StoresPage.kt | 95 +- app/src/main/res/values-es/strings.xml | 3 - app/src/main/res/values-fr/strings.xml | 3 - app/src/main/res/values-ja/strings.xml | 3 - app/src/main/res/values-ko/strings.xml | 3 - app/src/main/res/values-ru/strings.xml | 3 - app/src/main/res/values-zh-rCN/strings.xml | 3 - app/src/main/res/values-zh-rHK/strings.xml | 3 - app/src/main/res/values-zh-rTW/strings.xml | 3 - app/src/main/res/values/strings.xml | 50 +- .../droiddeck/launcher/ui/GlideShapeTest.kt | 29 + .../com/droiddeck/launcher/ui/InPaneTest.kt | 20 + .../droiddeck/launcher/ui/LayerStepTest.kt | 24 + .../droiddeck/launcher/ui/LiftSheetTest.kt | 18 + .../com/droiddeck/launcher/ui/PageExitTest.kt | 31 + 43 files changed, 4351 insertions(+), 1819 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/Hop.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt delete mode 100644 app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/Steps.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 1db068128..58d8599f5 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -46,7 +46,6 @@ import com.droiddeck.launcher.session.GameSaves import com.droiddeck.launcher.session.SessionLogShare import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SteamRepair -import com.droiddeck.launcher.ui.ProtonPage import com.droiddeck.launcher.core.CpuCores import com.droiddeck.launcher.core.PhantomProcessLimit import com.droiddeck.launcher.core.PhantomProcessStatus @@ -55,7 +54,6 @@ import com.droiddeck.launcher.core.WifiDiscovery import com.droiddeck.launcher.core.WirelessAdbPairingService import com.droiddeck.launcher.ui.CoreRow import com.droiddeck.launcher.ui.PerformancePage -import com.droiddeck.launcher.ui.ModeSettingsPage import com.droiddeck.launcher.ui.ModeSettings import com.droiddeck.launcher.ui.ModeSettingsActions import com.droiddeck.launcher.ui.ConfirmDialog @@ -124,7 +122,6 @@ class MainActivity : ComponentActivity() { private var steamRepairQueued by mutableStateOf(false) private var mangoapp by mutableStateOf(true) private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK) - private var showProtons by mutableStateOf(false) // Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager). private var showComponents by mutableStateOf(false) private var focusComponentsContent by mutableStateOf(true) @@ -166,8 +163,6 @@ class MainActivity : ComponentActivity() { private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) private var launcherFullscreen by mutableStateOf(true) private var animationsEnabled by mutableStateOf(true) - private var storeEnabled by mutableStateOf(false) - private var gameStoresEnabled by mutableStateOf(false) private var gameStoresSpeedTier by mutableStateOf("fast") private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY) /** A page a notification asked for ("stores:downloads", "app:"), until the front end has gone there. */ @@ -338,7 +333,7 @@ class MainActivity : ComponentActivity() { romsDir = path } } - // The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings(). + // The mode whose lifted sheet last read its settings, with what it shows; refreshed by loadModeSettings(). private var settingsMode by mutableStateOf(null) private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION) private var fexPreset by mutableStateOf("") @@ -539,8 +534,6 @@ class MainActivity : ComponentActivity() { requestedOrientation = orientation launcherFullscreen = SessionPrefs.launcherFullscreen(this) animationsEnabled = SessionPrefs.animationsEnabled(this) - storeEnabled = SessionPrefs.storeEnabled(this) - gameStoresEnabled = SessionPrefs.gameStoresEnabled(this) gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this) storesOpenTab = SessionPrefs.storesOpenTab(this) storesShowMature = SessionPrefs.storesShowMature(this) @@ -549,11 +542,8 @@ class MainActivity : ComponentActivity() { setContent { DroidDeckTheme(theme, appScale = appScale) { com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) { - val sm = settingsMode val page: (@Composable () -> Unit)? = when { - sm != null -> { { ModeSettingsHost(sm) } } showPerformance -> { { PerformanceHost() } } - showProtons -> { { ProtonHost() } } showComponents -> { { ComponentsHost() } } showMapping -> { { MappingHost() } } else -> null @@ -572,7 +562,7 @@ class MainActivity : ComponentActivity() { steamGames = steamGames, emulators = emulatorList, running = runningLabel, frameGen = FrameGen.mode(this), lossless = lossless, - pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, + pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null, theme = theme, language = com.droiddeck.launcher.core.AppLanguage.chosen(this), appScale = appScale, @@ -597,8 +587,6 @@ class MainActivity : ComponentActivity() { orientation = orientation, launcherFullscreen = launcherFullscreen, animationsEnabled = animationsEnabled, - storeEnabled = storeEnabled, - gameStoresEnabled = gameStoresEnabled, gameStoresSpeedTier = gameStoresSpeedTier, storesOpenTab = storesOpenTab, navigate = navRequest, @@ -683,9 +671,10 @@ class MainActivity : ComponentActivity() { }, // The activity re-attaches to the session that is running; nothing restarts. onResume = { startActivity(Intent(this, SessionActivity::class.java)) }, - onSteamSettings = { openModeSettings(SessionService.MODE_STEAM) }, + onSteamSettings = { loadModeSettings(SessionService.MODE_STEAM) }, onRemoveDesktop = { removeDesktop() }, - onDesktopSettings = { openModeSettings(SessionService.MODE_DESKTOP) }, + onDesktopSettings = { loadModeSettings(SessionService.MODE_DESKTOP) }, + modeSheet = { mode, tab, extras -> ModeSheetHost(mode, tab, extras) }, onInstallPackage = { id -> installPackage(id) }, onRemovePackage = { id -> removePackage(id) }, onRuntime = { onRuntimeButton() }, @@ -698,10 +687,11 @@ class MainActivity : ComponentActivity() { }, onProtons = { openProtons() }, onComponents = { focusContent -> openComponents(focusContent) }, + onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) }, // A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread. onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } }, onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } }, - onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true }, + onPerformance = { refreshCores(); showComponents = false; showMapping = false; showPerformance = true }, onRoms = { showRoms = true }, onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) }, onBrowseFiles = { dir -> @@ -736,7 +726,7 @@ class MainActivity : ComponentActivity() { OfflineMode.setEnabled(this, !OfflineMode.enabled(this)) offline = OfflineMode.enabled(this) }, - onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false }, + onPageBack = { showPerformance = false; showComponents = false; showMapping = false }, onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id }, onLanguage = { tag -> if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) { @@ -759,8 +749,6 @@ class MainActivity : ComponentActivity() { launcherFullscreen = on applyLauncherFullscreen() }, - onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on }, - onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on; if (on) com.droiddeck.launcher.stores.StoresState.refresh(this) }, onGameStoresSpeedTier = { tier -> SessionPrefs.setGameStoresSpeedTier(this, tier); gameStoresSpeedTier = tier }, onStoresOpenTab = { tab -> SessionPrefs.setStoresOpenTab(this, tab); storesOpenTab = tab }, onNavigated = { navRequest = null }, @@ -846,7 +834,7 @@ class MainActivity : ComponentActivity() { onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, - onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true }, + onMapping = { showPerformance = false; showComponents = false; showMapping = true }, onResetAll = { ControllerPrefs.resetAll(this); refreshController() }, ), ), @@ -966,14 +954,12 @@ class MainActivity : ComponentActivity() { } } - /** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */ + /** On the Components page the pad's LB / RB step through the stack's layers, wrapping around. */ override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.keyCode == KeyEvent.KEYCODE_BUTTON_A) com.droiddeck.launcher.ui.HeldKeys.confirm = event.action == KeyEvent.ACTION_DOWN if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS - val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1 - components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size] + components.compComp = com.droiddeck.launcher.ui.layerStep(components.compComp, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1) } return true } @@ -995,7 +981,7 @@ class MainActivity : ComponentActivity() { refreshWifiDiscovery() // Back from a store's sign-in page: the Stores chips show the account at once. A store // install finishing while this screen is up rebuilds the Games list through the listener. - if (gameStoresEnabled) com.droiddeck.launcher.stores.StoresState.refresh(this) + com.droiddeck.launcher.stores.StoresState.refresh(this) com.droiddeck.launcher.stores.StoresState.libraryListener = { refreshAddedGames(); refresh() } com.droiddeck.launcher.ui.Motion.refresh(this) // Back from a session stopped behind a flood: open on its blue, before the first frame. @@ -1126,14 +1112,8 @@ class MainActivity : ComponentActivity() { }, "game-saves-action").start() } - private fun openProtons() { - settingsMode = null - showPerformance = false - showComponents = false - showMapping = false - showProtons = true - protons.refreshProtons() - } + /** The Protons DroidDeck installs are the Components page's Proton layer. */ + private fun openProtons() = openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.PROTON_TAB) private fun refreshController() { controllerSettings = ControllerPrefs.read(this) @@ -1153,13 +1133,18 @@ class MainActivity : ComponentActivity() { private fun openComponents(focusContent: Boolean = true, tab: String? = null) { focusComponentsContent = focusContent if (tab != null) components.compComp = tab - settingsMode = null showPerformance = false - showProtons = false showMapping = false showComponents = true + // The Proton layer's options for every Proton, as they are now. + fexPreset = SessionPrefs.fexPreset(this) + forceSsbs = SessionPrefs.forceSsbs(this) + fastSync = SessionPrefs.fastSync(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) + syncFallback = SessionPrefs.syncFallback(this) components.refreshComponents(snapshotFirst = true) drivers.refreshDrivers() + protons.refreshProtons() } private fun importComponent(uri: Uri) { @@ -1193,7 +1178,8 @@ class MainActivity : ComponentActivity() { onDownload = { components.downloadComponent(it) }, onRefresh = { components.refreshComponentCatalog() }, onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) }, - onBack = { showComponents = false }, + // Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came. + onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false }, gpu = drivers.state(), gpuActions = com.droiddeck.launcher.ui.GpuDriversActions( onAuto = { on -> drivers.setMode(on) }, @@ -1210,23 +1196,31 @@ class MainActivity : ComponentActivity() { onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) }, onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() }, ), + protons = com.droiddeck.launcher.ui.ProtonsState( + rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent, + runtimeReady = ready && !busy, sessionRunning = SessionState.running, + ), + protonActions = com.droiddeck.launcher.ui.ProtonsActions( + onInstall = { id -> protons.installProton(id) }, + onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, + onRemove = { id -> protons.removeProton(id) }, + ), + protonOptions = { + com.droiddeck.launcher.ui.ProtonOptions( + syncBackend = SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback), fexPreset = fexPreset, forceSsbs = forceSsbs, + onSyncBackend = { id -> + SessionPrefs.setSyncBackend(this, id) + fastSync = SessionPrefs.fastSync(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) + syncFallback = SessionPrefs.syncFallback(this) + }, + onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id }, + onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on }, + ) + }, ) - } - - @Composable - private fun ProtonHost() { - ProtonPage( - rows = protons.protonRows, - busyId = protons.protonBusyId, - stage = protons.protonStage, - percent = protons.protonPercent, - runtimeReady = ready && !busy, - sessionRunning = SessionState.running, - onInstall = { id -> protons.installProton(id) }, - onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() }, - onRemove = { id -> protons.removeProton(id) }, - onBack = { showProtons = false }, - ) + // A Proton installed or removed changes the stack's list of Protons. + androidx.compose.runtime.LaunchedEffect(protons.protonBusyId) { if (protons.protonBusyId == null) components.refreshComponents() } } private fun refreshPackages() { @@ -1273,8 +1267,10 @@ class MainActivity : ComponentActivity() { } @Composable - private fun ModeSettingsHost(mode: String) { - ModeSettingsPage( + private fun ModeSheetHost(mode: String, tab: String, extras: com.droiddeck.launcher.ui.SheetExtras) { + // Read for the other mode last (or not yet): what this one shows is read as it opens. + if (settingsMode != mode) return + com.droiddeck.launcher.ui.ModeSheetRows( ModeSettings( mode = mode, resolution = resolution, panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this), @@ -1394,8 +1390,9 @@ class MainActivity : ComponentActivity() { onPickDeckyPluginZip = { pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.main_pick_decky_zip))) }, - onDismiss = { settingsMode = null }, + onDismiss = {}, ), + tab, extras, ) } @@ -1496,13 +1493,9 @@ class MainActivity : ComponentActivity() { private var pipAutoEnter by mutableStateOf(false) private var unfoldedControls by mutableStateOf(false) - private fun openModeSettings(mode: String) { + private fun loadModeSettings(mode: String) { pipAutoEnter = SessionPrefs.pipAutoEnter(this) unfoldedControls = SessionPrefs.unfoldedControls(this) - showPerformance = false - showProtons = false - showComponents = false - showMapping = false resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) fexPreset = SessionPrefs.fexPreset(this) forceSsbs = SessionPrefs.forceSsbs(this) @@ -1530,8 +1523,8 @@ class MainActivity : ComponentActivity() { gameStorage = SessionPrefs.gameStorage(this) storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this) settingsMode = mode - // The page opens at once, on what was last read; the slow part (driver files, a walk of the - // added-games folders, the storage volumes) lands while it animates in. + // The sheet lifts at once, on what was last read; the slow part (driver files, a walk of the + // added-games folders, the storage volumes) lands while it rises. Thread({ drivers.refreshDrivers() val storage = GameStorage.options(this) @@ -1607,7 +1600,10 @@ class MainActivity : ComponentActivity() { val scanGeneration = ++libraryScanGeneration if (shortcutPicker) shortcutLibraryScanning = true Thread({ - // Steam's own games first, from their manifests alone (quick), beside the added games + // The emulators first: they need no walk of the games, and Desktop's dock is made of them. + val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } + ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus } + // Steam's own games next, from their manifests alone (quick), beside the added games // already listed: a game Steam installed during the session shows as the session ends, // without waiting for the added games' walk below. Its Windows components' list is // queued then too, on the low-priority worker, so its page is usually ready. @@ -1628,7 +1624,6 @@ class MainActivity : ComponentActivity() { // so a partial list first would shuffle every capsule when the rest arrived. // LibraryCache covers the wait. val games = if (ready) Library.launchableGames(this) else emptyList() - val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } val all = games.distinctBy { it.gameId } if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all) // Once per app start: the Steam games' Windows components from Steam's lists. @@ -1643,7 +1638,6 @@ class MainActivity : ComponentActivity() { ui.post { if (scanGeneration == libraryScanGeneration) { steamGames = all - emulatorList = emus shortcutLibraryScanning = false handleGameLink(all) } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index f24b267cd..971608106 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -110,6 +110,9 @@ object DesktopCatalog { return File(root, DesktopRemoval.PENDING).exists() || marker(context, DESKTOP_ID).exists() } + /** The installed desktop's size on disk; off the main thread. */ + fun desktopSize(context: Context): Long = DesktopRemoval.installedSize(context) + fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) = DesktopRemoval.cleanLegacy(context, listener) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt index 3f3b493e5..09c93d73d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopRemoval.kt @@ -19,6 +19,14 @@ internal object DesktopRemoval { private fun protected(context: Context, root: File) = DesktopFileRemoval.runtimeFiles(root) + lines(context, "emulators-keep.txt.gzip") + /** + * What the desktop's own files take on disk, from the same inventory its removal uses (each + * file's size); the bundled apps' and emulators' shared files are not counted. Reads an asset: + * call it off the main thread. + */ + fun installedSize(context: Context): Long = + lines(context, "kde-remove.tsv.gzip").sumOf { line -> line.split('\t').getOrNull(1)?.toLongOrNull() ?: 0L } + /** Called before a guest starts or just after KDE extraction; retries after a process death. */ fun cleanLegacy(context: Context, listener: LinuxRuntimeInstaller.ProgressListener? = null) { val root = LinuxRuntime.rootDir(context) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index cb2e1cbd6..5e1387cab 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -68,20 +68,6 @@ object SessionPrefs { prefs(context).edit().putBoolean("animations", on).apply() } - /** The Flathub Store (beta): its rail item. Off by default. */ - fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false) - - fun setStoreEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("storeEnabled", on).apply() - } - - /** The Stores section (GOG, Epic Games, Amazon Games): its rail item. Off by default. */ - fun gameStoresEnabled(context: Context): Boolean = prefs(context).getBoolean("gameStoresEnabled", false) - - fun setGameStoresEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean("gameStoresEnabled", on).apply() - } - /** The store download engine's speed tier (stores/download/StoreDownloadTier ids); Fast unless chosen otherwise. */ fun gameStoresSpeedTier(context: Context): String = prefs(context).getString("gameStoresSpeedTier", "fast") ?: "fast" diff --git a/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt index b2c35911a..cd47019d8 100644 --- a/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt +++ b/app/src/main/java/com/droiddeck/launcher/store/StoreState.kt @@ -48,6 +48,9 @@ object StoreState { private set var searching by mutableStateOf(false) private set + + /** An app whose Flathub page was asked for from outside the store (its card on Desktop), once. */ + var openRequest by mutableStateOf(null) private var searchSeq = 0 val SECTIONS = listOf("popular" to R.string.store_section_popular, "trending" to R.string.store_section_trending, "recently-added" to R.string.store_section_new, "recently-updated" to R.string.store_section_updated) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 47e38d76d..e80156695 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -1,21 +1,27 @@ package com.droiddeck.launcher.ui -import androidx.compose.ui.platform.LocalContext -import com.droiddeck.launcher.R -import androidx.compose.ui.res.stringResource import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -23,16 +29,13 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Refresh -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -41,28 +44,60 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role 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.dp import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R import com.droiddeck.launcher.session.ComponentsManager import com.droiddeck.launcher.session.ComponentsManager.CatalogItem import com.droiddeck.launcher.session.ComponentsManager.Snapshot +import kotlinx.coroutines.delay import java.text.DateFormat import java.util.Date +/** The Components page's layer for the GPU drivers, under every Proton's. */ +const val GPU_TAB = "gpu" + +/** The Components page's layer for the Proton itself, the one the indented layers belong to. */ +const val PROTON_TAB = "proton" + +/** The stack's layers, top to bottom: LB / RB and the d-pad walk them in this order. */ +internal val LAYERS = listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB) + +/** The layer [by] steps from [current] (LB -1, RB +1), wrapping round the stack. */ +internal fun layerStep(current: String, by: Int): String { + val i = LAYERS.indexOf(current).coerceAtLeast(0) + return LAYERS[((i + by) % LAYERS.size + LAYERS.size) % LAYERS.size] +} + /** One installed row: a Proton's original bundle (one per Proton build) or a stored package. */ private class InstalledItem( val name: String, val detail: String, val tag: String, @@ -74,15 +109,18 @@ private class InstalledItem( ) private val GOLD = Color(0xFFF2C66D) +private val QUEUED = Color(0xFFFFB86B) private val ROW_SHAPE = RoundedCornerShape(10.dp) +private val LAYER_SHAPE = RoundedCornerShape(12.dp) /** - * FEX, DXVK and VKD3D-Proton per Proton, as a full page opened from the rail's "Components" entry. - * A title row with the Nightlies refresh, then the Proton and the component (LB / RB turn it), what - * is in use, and the installed packages beside the ones on the Nightlies - stacked when the page is - * narrow. Tapping an installed row swaps it in; the refresh button is the only thing that goes online. + * Everything a game runs on, as one stack: the Proton, its DXVK, VKD3D-Proton and FEX indented + * under it because they belong to it, then the GPU drivers that every Proton shares. One selection + * glides along the stack (LB / RB, or the d-pad in it); beside it a detail frame that never moves - + * what is in use, what is installed and what is available, and what else can be done - only its + * rows trade places. A swap is asked for by stepping out of its row, and the row flies home to its + * layer. On a narrow page the stack is the page, and a layer's detail floods out of its card. */ -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @Composable fun ComponentsPage( snapshot: Snapshot?, @@ -105,212 +143,338 @@ fun ComponentsPage( onImport: () -> Unit, onBack: () -> Unit, requestInitialFocus: Boolean = true, - /** The GPU drivers tab ([GPU_TAB]): what it shows and does. */ + /** The GPU drivers layer ([GPU_TAB]): what it shows and does. */ gpu: GpuDriversState = GpuDriversState(), gpuActions: GpuDriversActions = GpuDriversActions(), + /** The Proton layer ([PROTON_TAB]): the Protons DroidDeck can install, and installing them. */ + protons: ProtonsState = ProtonsState(), + protonActions: ProtonsActions = ProtonsActions(), + /** The Proton layer's options for every Proton (sync, FEX preset, SSBS, environment). */ + protonOptions: @Composable () -> Unit = {}, ) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val narrow = LocalNarrowPane.current - BackHandler(onBack = onBack) - var confirm by remember { mutableStateOf Unit>?>(null) } - var confirmTitle by remember { mutableStateOf("") } - var about by remember { mutableStateOf(false) } - var protonMenu by remember { mutableStateOf(false) } - var confirmVerb by remember { mutableStateOf(R.string.comp_swap) } val ctx = LocalContext.current - fun ask(title: String, body: String, verb: Int, action: () -> Unit) { confirmTitle = title; confirmVerb = verb; confirm = body to action } - // The GPU drivers tab's Advanced pages: one driver list on its own, full page. - var driverPage by remember { mutableStateOf(null) } - val gpuTab = comp == GPU_TAB - when (driverPage) { - "rt" -> { - DriverPage( - title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint), - rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads, - status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false, - onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh, - onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null }, - ) - return - } - "panel" -> { - DriverPage( - title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint), - rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads, - status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools), - canRestore = gpu.canRestoreBundled, - onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh, - onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled, - onBack = { driverPage = null }, - ) - return - } - } + val density = LocalDensity.current + val inputMode = LocalInputModeManager.current + val layer = if (comp in LAYERS) comp else GPU_TAB + BackHandler(onBack = onBack) val views = snapshot?.protons ?: emptyList() val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull() - val label = ComponentsManager.LABEL[comp] ?: comp - val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else stringResource(R.string.comp_never) - val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else stringResource(R.string.comp_nightlies_checked, checked) - Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) { - // ---- title, and the one thing that goes online ----------------------------------------- - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - Text( - stringResource(R.string.comp_title), fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, - maxLines = 1, - ) - // The status gives way to the title, not the other way round. - if (!narrow) Text( - nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f), - ) else Spacer(Modifier.weight(1f)) - if (!gpuTab) ToolIcon(Icons.Outlined.Info, stringResource(R.string.comp_about)) { about = true } - if (gpuTab) ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_drivers), busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh) - else ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_nightlies), busy = checking, enabled = !checking, onClick = onRefresh) - } - if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + // Where things sit, root px: each layer card, each row (for a swap's dot), the page itself. + val cardsAt = remember { mutableStateMapOf() } + val rowsAt = remember { HashMap() } + var stackAt by remember { mutableStateOf(Offset.Zero) } + var pageAt by remember { mutableStateOf(Offset.Zero) } + // A layer card pops and rings as a swap's dot lands on it, and rings as a hop arrives on it. + val landed = remember { mutableStateMapOf() } + val rings = remember { mutableStateMapOf() } + fun land(l: String) { landed[l] = (landed[l] ?: 0) + 1; rings[l] = (rings[l] ?: 0) + 1 } + val arrivedSeen = remember { intArrayOf(Hops.arrived) } + LaunchedEffect(Hops.arrived) { + if (Hops.arrived == arrivedSeen[0]) return@LaunchedEffect + arrivedSeen[0] = Hops.arrived + // Once the page has flooded in from the rail. + delay(Motion.ms(700).toLong()) + rings[layer] = (rings[layer] ?: 0) + 1 + } + /** The row at [key] squashes into a dot and flies home to [to]'s card, which pops on landing. */ + fun flyHome(key: String, to: String) { + val from = rowsAt[key] ?: return land(to) + Flights.fly(Flight(from, to = { cardsAt[to]?.center }, onLand = { land(to) })) + } - // ---- which Proton, which component ------------------------------------------------------ - val comps = ComponentsManager.COMPONENTS - androidx.compose.foundation.layout.FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + // A confirm stepping out of the row that asked: right of a left-column row, left of a right one. + fun ask(key: String, right: Boolean, title: String, note: String, verb: String, danger: Boolean, face: String = "", then: () -> Unit) { + val at = rowsAt[key] ?: return then() + val cancel = ctx.getString(R.string.common_cancel) + Steps.ask(StepAsk( + anchor = at, side = if (right) StepSide.Left else StepSide.Right, + accent = if (danger) colors.error else pal.signal, pillCorner = 10.dp, items = 3, maxWidth = 340.dp, + // The row's own name rides on top, as the pull's handle. + handle = { + Box(Modifier.fillMaxSize().padding(start = 46.dp, end = 14.dp), contentAlignment = Alignment.CenterStart) { + Text(face, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else pal.signal, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + }, ) { - if (view != null && !gpuTab) Box { - ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu } - AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = stringResource(R.string.comp_proton), - note = stringResource(R.string.comp_proton_note)) { first -> - views.forEachIndexed { i, v -> - val swaps = v.components.values.count { it.activeFile != null } - MenuItem( - v.proton.name, checked = v.proton.id == view.proton.id, - detail = (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version)) - .let { - when (v.sync?.state) { - "pack" -> stringResource(R.string.comp_esync_pack, it) - "wanted" -> stringResource(R.string.comp_esync_wanted, it) - "builtin" -> stringResource(R.string.comp_esync_builtin, it) - else -> it - } - } - .let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it }, - focusRequester = if (i == 0) first else null, - ) { onProton(v.proton.id); protonMenu = false } - } - } + StepTitle(title) + if (note.isNotEmpty()) Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(verb, danger = danger, enabled = open) { Steps.fold(); then() } } - val tabs = listOf(GPU_TAB) + comps - TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: stringResource(R.string.comp_gpu_drivers) }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) }) - } + }) + } - // ---- what is in use ----------------------------------------------------------------------- - if (view != null && !gpuTab) { - val st = view.components.getValue(comp) - val reappliedAt = if (view.reappliedAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else null - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) - .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp).heightIn(min = 28.dp), - ) { - Box(Modifier.size(8.dp).background(if (st.queued != null) Color(0xFFFFB86B) else pal.good, CircleShape)) - Text( - (if (st.queued != null) stringResource(R.string.comp_next_queued, st.queued) else stringResource(R.string.comp_in_use, label, st.inUse)) - .let { if (reappliedAt != null) stringResource(R.string.comp_reapplied, it, reappliedAt) else it }, - fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - if (st.queued != null) FocusText(stringResource(R.string.common_cancel), pal.signal, onClick = onCancelQueued) - else if (!narrow) Text( - if (view.inUseByGame) stringResource(R.string.comp_waits) else stringResource(R.string.comp_applies_next_game), - fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, + // The runtime and display drivers on their own pages, flooding out of the link that opens them. + var driverPage by remember { mutableStateOf(null) } + var driverShown by remember { mutableStateOf(null) } + var driverLeaving by remember { mutableStateOf(false) } + var driverFrom by remember { mutableStateOf(null) } + LaunchedEffect(driverPage) { + val d = driverPage + if (d != null) { driverLeaving = false; driverShown = d } + else if (driverShown != null) { + if (driverFrom != null) { driverLeaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) } + driverShown = null; driverLeaving = false; driverFrom = null + } + } + // Narrow: a layer's detail floods out of its card, and B draws it back in. + var narrowOpen by rememberSaveable { mutableStateOf(null) } + var narrowShown by remember { mutableStateOf(null) } + var narrowLeaving by remember { mutableStateOf(false) } + LaunchedEffect(narrowOpen) { + val d = narrowOpen + if (d != null) { narrowLeaving = false; narrowShown = d } + else if (narrowShown != null) { + narrowLeaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) + narrowShown = null; narrowLeaving = false + } + } + if (!narrow && narrowOpen != null) narrowOpen = null + BackHandler(enabled = driverPage != null || narrowOpen != null) { + if (driverPage != null) driverPage = null else narrowOpen = null + } + // Leaving the page: nothing stays stepped out of a row that is no longer on screen. + androidx.compose.runtime.DisposableEffect(Unit) { onDispose { Steps.drop() } } + + val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else stringResource(R.string.comp_never) + val firstRow = remember { FocusRequester() } + val layerFocus = remember { LAYERS.associateWith { FocusRequester() } } + // Arrived (from the rail, a hop, a game's card): a pad starts on the chosen layer. + LaunchedEffect(requestInitialFocus) { + if (!requestInitialFocus || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + delay(Motion.ms(200).toLong()) + focusSoon { layerFocus.getValue(layer) } + } + var travel by remember { mutableIntStateOf(1) } + // LB / RB (taken by the activity before the page sees them): a pad anywhere on the page goes to + // the new layer's card, so one on a row - which leaves with its layer - is never left with nothing. + var pageFocused by remember { mutableStateOf(false) } + val seenLayer = remember { arrayOf(layer) } + LaunchedEffect(layer) { + if (seenLayer[0] == layer) return@LaunchedEffect + seenLayer[0] = layer + if (!pageFocused || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect + if (narrowOpen != null) runCatching { firstRow.requestFocus() } + else focusSoon { layerFocus.getValue(layer) } + } + val pick: (String) -> Unit = { l -> + if (l != layer) { travel = if (LAYERS.indexOf(l) > LAYERS.indexOf(layer)) 1 else -1; onComp(l) } + } + + // ---- one layer's detail, a slot at a time ------------------------------------------------------ + val compState = { l: String -> view?.components?.get(l) } + val bar: @Composable (String) -> Unit = { l -> + when (l) { + GPU_TAB -> { + val active = gpu.activePair + InUseBar( + queued = gpu.busy != null, + sentence = (active?.let { "${it.name} ${it.version}" } ?: gpu.activeBundle ?: stringResource(R.string.gpu_builtin)) + .let { if (gpu.auto) stringResource(R.string.gpu_bar_auto, it) else it }, + note = if (gpu.busy != null) (if (gpu.percent >= 0) "${gpu.percent}%" else "…") + else gpu.autoStatus.ifEmpty { if (active != null || gpu.activeBundle != null) "" else gpu.pairs.firstOrNull { it.recommended }?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint) }, ) } - } - - // ---- the lists ------------------------------------------------------------------------------ - when { - gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { - GpuDriversPanel(gpu, gpuActions) { driverPage = it } - Row(modifier = Modifier.padding(top = 14.dp)) { - SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip) - } - } - snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) { - CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(12.dp)) - Text(stringResource(R.string.comp_reading), fontSize = 14.sp, color = colors.onSurfaceVariant) - } - view == null -> Text( - stringResource(R.string.comp_no_proton), - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), + PROTON_TAB -> InUseBar( + queued = false, + sentence = view?.let { stringResource(R.string.comp_proton_bar, it.proton.name) } ?: stringResource(R.string.comp_no_proton), + note = if (view?.inUseByGame == true) stringResource(R.string.comp_waits) else "", ) else -> { - val p = view.proton - val st = view.components.getValue(comp) - val build = ComponentsManager.safeName(p.version) - val originals = view.originals.filter { it.comp == comp } - val stored = snapshot.packages.filter { it.comp == comp } - val activeIsOriginal = st.activeFile == null - val rows = originals.map { o -> - val isCurrent = o.protonVersion == build - InstalledItem( - stringResource(R.string.comp_original, o.protonVersion), - o.label.substringAfterLast(" · ", "").ifEmpty { o.label }.let { if (isCurrent) stringResource(R.string.comp_this_build, it) else stringResource(R.string.comp_earlier_build, it) }, - "ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion, + val st = compState(l) + val label = ComponentsManager.LABEL[l] ?: l + if (st == null) InUseBar(false, stringResource(R.string.comp_no_proton), "") + else { + val reappliedAt = view?.reappliedAt?.takeIf { it > 0 }?.let { DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(it * 1000)) } + InUseBar( + queued = st.queued != null, + sentence = (if (st.queued != null) stringResource(R.string.comp_next_queued, st.queued) else stringResource(R.string.comp_in_use, label, st.inUse)) + .let { if (reappliedAt != null) stringResource(R.string.comp_reapplied, it, reappliedAt) else it }, + note = if (view?.inUseByGame == true) stringResource(R.string.comp_waits) else stringResource(R.string.comp_applies_next_game), + onCancel = if (st.queued != null) onCancelQueued else null, ) - } + stored.map { s -> - InstalledItem(s.version, stringResource(R.string.common_size_mb, s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null) - } - val storedNames = snapshot.packages.map { it.file }.toSet() - val available = catalog.filter { it.comp == comp && ComponentsManager.safeName(it.file) !in storedNames } - val first = remember { FocusRequester() } - LaunchedEffect(p.id, comp, requestInitialFocus) { - if (requestInitialFocus) runCatching { first.requestFocus() } } + } + } + } - val installed: @Composable () -> Unit = { - SettingsGroup(stringResource(R.string.comp_installed_count, rows.size)) { - if (rows.isEmpty()) Text( - stringResource(R.string.comp_nothing_stored, label), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - ) - rows.forEachIndexed { i, item -> - InstalledLine( - item, modifier = if (i == 0) Modifier.focusRequester(first) else Modifier, - onSelect = { - if (item.selected) return@InstalledLine - val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long) - if (item.file != null) ask(ctx.getString(R.string.comp_swap_title, label), ctx.getString(R.string.comp_swap_body, item.name, p.name, waits), R.string.comp_swap) { onSwap(item.file) } - else ask(ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_body, item.protonVersion, p.name, waits), R.string.common_restore) { onRestore(item.protonVersion!!) } - }, - onDelete = { - if (item.file != null) ask(ctx.getString(R.string.common_delete_named, item.name) + "?", ctx.getString(R.string.comp_delete_package_body), R.string.common_delete) { onDeletePackage(item.file) } - else ask(ctx.getString(R.string.comp_delete_original_title), ctx.getString(R.string.comp_delete_original_body, item.protonVersion, p.name), R.string.common_delete) { onDeleteOriginal(item.protonVersion!!) } - }, - ) + // Installed and Available, as (title, rows) for a layer; [right] says which column it is in. + var showAllPairs by rememberSaveable { mutableStateOf(false) } + val installedTitle: @Composable (String) -> String = { l -> + when (l) { + GPU_TAB -> stringResource(R.string.comp_installed_count, 1 + gpu.pairs.count { it.installed && (it.suits || showAllPairs) }) + PROTON_TAB -> stringResource(R.string.comp_installed_count, views.size) + else -> stringResource(R.string.comp_installed_count, installedFor(l, view, snapshot).size) + } + } + val availableTitle: @Composable (String) -> String = { l -> + when (l) { + GPU_TAB -> stringResource(R.string.comp_available_count, gpu.pairs.count { !it.installed && it.complete && (it.suits || showAllPairs) }) + PROTON_TAB -> stringResource(R.string.comp_available_count, protons.rows.count { it.installed == null }) + else -> stringResource(R.string.comp_nightlies_count, availableFor(l, catalog, snapshot).size) + } + } + val installedRows: @Composable ColumnScope.(String, Boolean) -> Unit = { l, right -> + when (l) { + GPU_TAB -> { + val autoKey = "gpu:auto" + ChoiceLine( + stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_keeps), gpu.auto, removable = false, + modifier = Modifier.focusRequester(firstRow).trackRow(autoKey, rowsAt), + onSelect = { + if (gpu.auto) return@ChoiceLine + ask(autoKey, right, ctx.getString(R.string.gpu_use_title, ctx.getString(R.string.common_auto)), ctx.getString(R.string.gpu_auto_hint), ctx.getString(R.string.common_use), false, ctx.getString(R.string.common_auto)) { + gpuActions.onAuto(true); flyHome(autoKey, GPU_TAB) + } + }, + ) + for (p in gpu.pairs.filter { it.installed && (it.suits || showAllPairs) }) { + val key = "gpu:${p.key}" + ChoiceLine( + p.name, "${p.version} · ${p.detail}", !gpu.auto && p.active, removable = false, + badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null, + progress = if (gpu.busy == p.key) gpu.percent else null, + modifier = Modifier.trackRow(key, rowsAt), + onSelect = { + if (!gpu.auto && p.active || gpu.busy != null) return@ChoiceLine + ask(key, right, ctx.getString(R.string.gpu_use_title, p.name), "${p.version} · ${p.detail}", ctx.getString(R.string.common_use), false, p.name) { + gpuActions.onAuto(false); gpuActions.onPair(p.key); flyHome(key, GPU_TAB) + } + }, + ) + } + } + PROTON_TAB -> { + if (views.isEmpty()) EmptyLine(stringResource(if (snapshot == null) R.string.comp_reading else R.string.comp_no_proton)) + views.forEachIndexed { i, v -> + val key = "proton:${v.proton.id}" + val swaps = v.components.values.count { it.activeFile != null } + val extra = protons.rows.firstOrNull { it.installed == v.proton.dir.name } + val removable = extra != null && protons.busyId == null && protons.runtimeReady && !protons.sessionRunning + ChoiceLine( + v.proton.name, + (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version)) + .let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it }, + v.proton.id == view?.proton?.id, removable = removable, + modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt), + onSelect = { if (v.proton.id != view?.proton?.id) { onProton(v.proton.id); flyHome(key, PROTON_TAB) } }, + onDelete = { + if (extra != null) ask(key, right, ctx.getString(R.string.proton_remove_title, v.proton.name), "", ctx.getString(R.string.store_remove), true, v.proton.name) { + protonActions.onRemove(extra.id) + } + }, + ) + } + } + else -> { + val label = ComponentsManager.LABEL[l] ?: l + val rows = installedFor(l, view, snapshot) + if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_stored, label)) + val p = view?.proton + rows.forEachIndexed { i, item -> + val key = "row:$l:${item.file ?: item.protonVersion}" + ChoiceLine( + item.name, item.detail, item.selected, item.removable, tag = item.tag, + modifier = (if (i == 0) Modifier.focusRequester(firstRow) else Modifier).trackRow(key, rowsAt), + onSelect = { + if (item.selected || p == null) return@ChoiceLine + val waits = if (view.inUseByGame) ctx.getString(R.string.comp_waits_long) else ctx.getString(R.string.comp_applies_long) + if (item.file != null) ask(key, right, ctx.getString(R.string.comp_swap_title, label), ctx.getString(R.string.comp_swap_body, item.name, p.name, waits), ctx.getString(R.string.comp_swap), false, item.name) { + onSwap(item.file); flyHome(key, l) + } + else ask(key, right, ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_body, item.protonVersion, p.name, waits), ctx.getString(R.string.common_restore), false, item.name) { + onRestore(item.protonVersion!!); flyHome(key, l) + } + }, + onDelete = { + if (p == null) return@ChoiceLine + if (item.file != null) ask(key, right, ctx.getString(R.string.common_delete_named, item.name) + "?", ctx.getString(R.string.comp_delete_package_body), ctx.getString(R.string.common_delete), true, item.name) { onDeletePackage(item.file) } + else ask(key, right, ctx.getString(R.string.comp_delete_original_title), ctx.getString(R.string.comp_delete_original_body, item.protonVersion, p.name), ctx.getString(R.string.common_delete), true, item.name) { onDeleteOriginal(item.protonVersion!!) } + }, + ) + } + } + } + } + val availableRows: @Composable ColumnScope.(String) -> Unit = { l -> + when (l) { + GPU_TAB -> { + val shown = gpu.pairs.filter { !it.installed && it.complete && (it.suits || showAllPairs) } + if (shown.isEmpty()) EmptyLine(stringResource(if (gpu.checking) R.string.gpu_checking else R.string.gpu_none_listed)) + for (p in shown) GetLine( + p.name, "${p.version} · ${p.detail}", progress = if (gpu.busy == p.key) gpu.percent else null, enabled = gpu.busy == null, + badge = if (p.recommended) stringResource(R.string.gpu_recommended_caps) else null, + ) { gpuActions.onAuto(false); gpuActions.onPair(p.key) } + if (gpu.pairs.any { !it.suits }) Box(Modifier.padding(horizontal = 6.dp, vertical = 4.dp)) { + FocusText(stringResource(if (showAllPairs) R.string.gpu_hide_other else R.string.gpu_show_other), pal.signal) { showAllPairs = !showAllPairs } + } + } + PROTON_TAB -> { + val rows = protons.rows.filter { it.installed == null } + if (rows.isEmpty()) EmptyLine(stringResource(R.string.comp_nothing_new)) + for (row in rows) GetLine( + row.name, if (row.queued) stringResource(R.string.proton_queued) else stringResource(R.string.proton_not_installed), + progress = if (protons.busyId == row.id) protons.percent else null, + stage = if (protons.busyId == row.id) protons.stage else null, + enabled = protons.busyId == null && protons.runtimeReady && !protons.sessionRunning, + onCancel = if (row.queued && protons.busyId == null && !protons.sessionRunning) ({ protonActions.onCancel(row.id) }) else null, + ) { protonActions.onInstall(row.id) } + } + else -> { + val available = availableFor(l, catalog, snapshot) + if (available.isEmpty()) EmptyLine(if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new)) + for (d in available) GetLine( + d.file.removeSuffix(".wcp"), "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), + progress = downloads[d.file], enabled = busy == null, + ) { onDownload(d) } + } + } + } + val actions: @Composable (String) -> Unit = { l -> + Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) { + when (l) { + GPU_TAB -> { + SmallButton(stringResource(R.string.comp_import_zip), onClick = gpuActions.onImportZip) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + FocusText(stringResource(R.string.gpu_separately) + " ›", pal.signal) { + val from = at + val title = ctx.getString(R.string.gpu_separately) + if (from == null) { driverPage = "rt"; return@FocusText } + Steps.ask(StepAsk(anchor = from, side = StepSide.Right, accent = pal.signal, pillCorner = 10.dp, items = 3, handle = {}) { + StepTitle(title) + StepList { + StepChoice(ctx.getString(R.string.comp_runtime_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(1).focusRequester(first)) { + Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "rt" + } + StepChoice(ctx.getString(R.string.comp_display_driver), enabled = open, modifier = Modifier.fillMaxWidth().stepItem(2)) { + Steps.fold(); driverFrom = Origin(from.translate(-pageAt), 10f * density.density); driverPage = "panel" + } + } + }) } } } - val nightly: @Composable () -> Unit = { - SettingsGroup(stringResource(R.string.comp_nightlies_count, available.size)) { - if (available.isEmpty()) Text( - if (catalogAt == 0L) stringResource(R.string.comp_refresh_to_list) else stringResource(R.string.comp_nothing_new), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - ) - available.forEach { d -> AvailableLine(d, downloads[d.file], enabled = busy == null) { onDownload(d) } } - } - } - Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { - if (narrow) { installed(); nightly() } - else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { - Column(Modifier.weight(1f)) { installed() } - Column(Modifier.weight(1f)) { nightly() } - } - Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 14.dp)) { - SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport) - if (!activeIsOriginal && originals.any { it.protonVersion == build }) FocusText(stringResource(R.string.comp_restore_original), pal.signal) { - ask(ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_original_body, p.name, label), R.string.common_restore) { onRestore(build) } + PROTON_TAB -> {} + else -> { + SmallButton(stringResource(R.string.comp_import_wcp), onClick = onImport) + val st = compState(l) + val p = view?.proton + val build = p?.let { ComponentsManager.safeName(it.version) } + if (st != null && p != null && st.activeFile != null && view.originals.any { it.comp == l && it.protonVersion == build }) { + val label = ComponentsManager.LABEL[l] ?: l + Box(Modifier.trackRow("restore:$l", rowsAt)) { + FocusText(stringResource(R.string.comp_restore_original), pal.signal) { + ask("restore:$l", false, ctx.getString(R.string.comp_restore_title, label), ctx.getString(R.string.comp_restore_original_body, p.name, label), ctx.getString(R.string.common_restore), false, ctx.getString(R.string.comp_restore_original)) { + onRestore(build!!); flyHome("restore:$l", l) + } + } } } } @@ -318,34 +482,298 @@ fun ComponentsPage( } } - if (about) AlertDialog( - onDismissRequest = { about = false }, - title = { Text(stringResource(R.string.comp_title)) }, - text = { - Text( - stringResource(R.string.comp_about_text), - fontSize = 14.sp, - ) - }, - confirmButton = { FocusText(stringResource(R.string.common_ok), pal.signal) { about = false } }, - ) - confirm?.let { (body, action) -> - AlertDialog( - onDismissRequest = { confirm = null }, - title = { Text(confirmTitle) }, - text = { Text(body, fontSize = 14.sp) }, - // Opens on Cancel, so a stray A press on a controller never swaps or deletes anything. - confirmButton = { - val del = confirmVerb == R.string.common_delete - FocusText(stringResource(confirmVerb), if (del) colors.error else pal.signal) { confirm = null; action() } - }, - dismissButton = { - val cancelFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } } - FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null } + // ---- the frame ------------------------------------------------------------------------------ + val detail: @Composable (String, Boolean) -> Unit = { l, narrowPage -> + androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { + // Two columns while each gets room for a row's name and its Get; one under the other below that. + val single = narrowPage || maxWidth < 600.dp + Column(Modifier.fillMaxSize()) { + // The in-use bar keeps its place; only its words change. + AnimatedContent(l, transitionSpec = { fadeIn(Motion.tw(240)) togetherWith fadeOut(Motion.tw(160)) }, label = "inUse") { k -> bar(k) } + Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(top = 12.dp, bottom = 16.dp)) { + val slide: @Composable (@Composable (String) -> Unit) -> Unit = { body -> + AnimatedContent( + l, + transitionSpec = { + val d = travel + val shift = with(density) { 16.dp.roundToPx() } + (slideInVertically(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240))) + .togetherWith(slideOutVertically(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240))) + }, + label = "layerRows", + ) { k -> body(k) } + } + if (single) { + slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } + Spacer(Modifier.height(12.dp)) + slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } + } else Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { slide { k -> RowGroup(installedTitle(k)) { installedRows(k, false) } } } + Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } } + } + Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } } + if (l == PROTON_TAB) { + Spacer(Modifier.height(10.dp)) + if (!protons.runtimeReady) EmptyLine(stringResource(R.string.content_needs_runtime)) + if (protons.sessionRunning) EmptyLine(stringResource(R.string.proton_stop_first)) + SettingsGroup(stringResource(R.string.comp_for_every_proton)) { protonOptions() } + Spacer(Modifier.height(12.dp)) + Note(stringResource(R.string.comp_about_text)) + } + } + } + } + } + + Box(Modifier.fillMaxSize().onGloballyPositioned { pageAt = it.positionInRoot() }.onFocusChanged { pageFocused = it.hasFocus }) { + Column( + Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp) + .bumpers(onPrevious = { pick(layerStep(layer, -1)) }, onNext = { pick(layerStep(layer, 1)) }), + ) { + // ---- header: the title, the device, when the lists were checked and the one refresh ---- + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.comp_title), fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1) + if (!narrow) DeviceChip(gpu) + Spacer(Modifier.weight(1f)) + HopBackChip(onBack) + if (!narrow) Text( + if (busy != null) "$busy…" else stringResource(R.string.comp_checked, checked), + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + val refreshing = checking || gpu.checking + ToolIcon(Icons.Outlined.Refresh, stringResource(R.string.comp_check_all), busy = refreshing, enabled = !refreshing && gpu.busy == null) { + onRefresh(); gpuActions.onRefresh() + } + } + if (narrow) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 6.dp)) { DeviceChip(gpu) } + Spacer(Modifier.height(14.dp)) + Row(Modifier.weight(1f).fillMaxWidth()) { + // ---- the stack ---- + Box( + (if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight() + .onGloballyPositioned { stackAt = it.positionInRoot() }, + ) { + val tint = rememberUpdatedState(pal.signal) + val local = cardsAt[layer]?.translate(-stackAt) + ChipGlide(LAYERS.indexOf(layer), local, tint, GlideAxis.Vertical, corner = 12.dp) + Stack( + layer, view, gpu, focus = layerFocus, landed = landed, rings = rings, + onPlaced = { l, r -> cardsAt[l] = r }, + onFocus = { l -> pick(l) }, + onOpen = { l -> + pick(l) + if (narrow) narrowOpen = l + else runCatching { firstRow.requestFocus() } + }, + ) + } + if (!narrow) { + Spacer(Modifier.width(16.dp)) + Box(Modifier.weight(1f).fillMaxHeight()) { detail(layer, false) } + } + } + } + // Narrow: the layer's detail over the stack, out of its card. + narrowShown?.let { l -> + val from = cardsAt[l]?.let { Origin(it.translate(-pageAt), 12f * density.density) } + val body: @Composable () -> Unit = { + Column( + Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } } + .padding(horizontal = 16.dp, vertical = 12.dp).focusGroup(), + ) { + BackLink(LayerTitle(l), compact = true) { narrowOpen = null } + Spacer(Modifier.height(8.dp)) + detail(layer, true) + } + } + if (from == null) body() else PageFlood(from, narrowLeaving) { body() } + } + // The runtime or display driver on its own page. + driverShown?.let { d -> + val body: @Composable () -> Unit = { + Box(Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } }) { + if (d == "rt") DriverPage( + title = stringResource(R.string.comp_runtime_driver), hint = stringResource(R.string.comp_runtime_driver_hint), + rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_turnip), canRestore = false, + onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null }, + ) + else DriverPage( + title = stringResource(R.string.comp_display_driver), hint = stringResource(R.string.comp_display_driver_hint), + rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads, + status = gpu.releaseStatus, checking = gpu.checking, importLabel = stringResource(R.string.comp_import_adrenotools), + canRestore = gpu.canRestoreBundled, + onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh, + onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled, + onBack = { driverPage = null }, + ) + } + } + val from = driverFrom + if (from == null) body() else PageFlood(from, driverLeaving) { body() } + } + } +} + +/** The Installed rows of one component layer for the Proton the page shows. */ +@Composable +private fun installedFor(l: String, view: ComponentsManager.ProtonView?, snapshot: Snapshot?): List { + view ?: return emptyList() + val st = view.components[l] ?: return emptyList() + val build = ComponentsManager.safeName(view.proton.version) + val activeIsOriginal = st.activeFile == null + return view.originals.filter { it.comp == l }.map { o -> + val isCurrent = o.protonVersion == build + InstalledItem( + stringResource(R.string.comp_original, o.protonVersion), + o.label.substringAfterLast(" · ", "").ifEmpty { o.label }.let { if (isCurrent) stringResource(R.string.comp_this_build, it) else stringResource(R.string.comp_earlier_build, it) }, + "ORIGINAL", activeIsOriginal && isCurrent, !isCurrent, null, o.protonVersion, + ) + } + (snapshot?.packages ?: emptyList()).filter { it.comp == l }.map { s -> + InstalledItem(s.version, stringResource(R.string.common_size_mb, s.size / 1048576.0), "STORED", s.file == st.activeFile, true, s.file, null) + } +} + +/** The Nightlies of one component layer that are not stored yet. */ +private fun availableFor(l: String, catalog: List, snapshot: Snapshot?): List { + val stored = (snapshot?.packages ?: emptyList()).map { it.file }.toSet() + return catalog.filter { it.comp == l && ComponentsManager.safeName(it.file) !in stored } +} + +@Composable +private fun LayerTitle(l: String): String = when (l) { + GPU_TAB -> stringResource(R.string.comp_gpu_drivers) + PROTON_TAB -> stringResource(R.string.comp_proton) + else -> ComponentsManager.LABEL[l] ?: l +} + +/** Records where a row sits, for the confirm that steps out of it and the dot that flies from it. */ +private fun Modifier.trackRow(key: String, into: HashMap): Modifier = onGloballyPositioned { into[key] = it.boundsInRoot() } + +/** + * The stack: a heading over the Proton and the three layers that belong to it (indented, on a + * line), then a heading over the GPU drivers that every Proton shares. Each card is a layer's name + * and what it is set to; a dot marks a layer swapped away from its original. + */ +@Composable +private fun Stack( + layer: String, view: ComponentsManager.ProtonView?, gpu: GpuDriversState, + focus: Map, landed: Map, rings: Map, + onPlaced: (String, Rect) -> Unit, onFocus: (String) -> Unit, onOpen: (String) -> Unit, +) { + val pal = LocalPalette.current + val original = stringResource(R.string.comp_tag_original) + val auto = stringResource(R.string.common_auto) + val card: @Composable (String) -> Unit = { l -> + val st = view?.components?.get(l) + LayerCard( + title = LayerTitle(l), + value = when (l) { + GPU_TAB -> if (gpu.auto) auto else gpu.activePair?.name ?: gpu.activeBundle ?: auto + PROTON_TAB -> (view?.proton?.name ?: "") + " ▾" + else -> st?.let { if (it.activeFile == null) original else it.inUse } ?: "" }, + swapped = l in ComponentsManager.COMPONENTS && st?.activeFile != null, + selected = l == layer, + landed = landed[l] ?: 0, ring = rings[l] ?: 0, + modifier = Modifier.focusRequester(focus.getValue(l)).onGloballyPositioned { onPlaced(l, it.boundsInRoot()) }, + onFocus = { onFocus(l) }, onClick = { onOpen(l) }, ) } + Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(3.dp)) { + StackHeading(stringResource(R.string.comp_this_proton)) + card(PROTON_TAB) + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + Box(Modifier.padding(start = 8.dp, top = 4.dp, bottom = 4.dp).width(2.dp).fillMaxHeight().background(pal.line2)) + Column(Modifier.weight(1f).padding(start = 6.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + card("dxvk"); card("vkd3d"); card("fex") + } + } + Spacer(Modifier.height(6.dp)) + StackHeading(stringResource(R.string.comp_every_proton)) + card(GPU_TAB) + } +} + +@Composable +private fun StackHeading(text: String) { + Text( + text.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.4.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp, top = 6.dp, bottom = 2.dp), + ) +} + +/** One layer: 40dp to see, 44 to touch; its name, what it is set to, and a dot when it is swapped. */ +@Composable +private fun LayerCard( + title: String, value: String, swapped: Boolean, selected: Boolean, landed: Int, ring: Int, + modifier: Modifier, onFocus: () -> Unit, onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + Box(Modifier.fillMaxWidth().heightIn(min = 44.dp), contentAlignment = Alignment.Center) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth().height(40.dp) + .landingPop(landed).pulseRing(ring, pal.signal) + .clip(LAYER_SHAPE) + .background(if (selected) Color.Transparent else colors.surface) + .glideBorder(hot, LAYER_SHAPE, pal.signal, if (selected) Color.Transparent else pal.line) + .onFocusChanged { if (it.isFocused) onFocus() } + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 12.dp), + ) { + if (swapped) Box(Modifier.size(7.dp).clip(CircleShape).background(pal.signal)) + Text(title, fontSize = 13.5.sp, fontWeight = FontWeight.Bold, color = if (selected || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1) + Spacer(Modifier.weight(1f)) + // The value ticks over as a swap lands. + AnimatedContent( + value, + transitionSpec = { (slideInVertically(Motion.tw(260)) { it / 2 } + fadeIn(Motion.tw(260))) togetherWith (slideOutVertically(Motion.tw(160)) { -it / 2 } + fadeOut(Motion.tw(160))) }, + label = "layerValue", + ) { v -> Text(v, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } +} + +/** What is in use for the layer: a dot (green, or amber while a change waits), a sentence, and a note or Cancel. */ +@Composable +private fun InUseBar(queued: Boolean, sentence: String, note: String, onCancel: (() -> Unit)? = null) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(RoundedCornerShape(12.dp)).background(colors.surface) + .border(1.dp, pal.line, RoundedCornerShape(12.dp)).padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Box(Modifier.size(8.dp).background(if (queued) QUEUED else pal.good, CircleShape)) + Text(sentence, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + if (onCancel != null) FocusText(stringResource(R.string.common_cancel), pal.signal, onClick = onCancel) + else if (note.isNotEmpty() && !LocalNarrowPane.current) Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** A titled column of rows. */ +@Composable +private fun RowGroup(title: String, content: @Composable ColumnScope.() -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp)) { + Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, maxLines = 1) + Box(Modifier.weight(1f).height(1.dp).background(pal.line)) + } + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(colors.surface).border(1.dp, pal.line, RoundedCornerShape(14.dp)), content = content) + } +} + +@Composable +private fun EmptyLine(text: String) { + Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) } @Composable @@ -353,10 +781,10 @@ internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fa val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val hot = rememberHot(src) Box( contentAlignment = Alignment.Center, - modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)) + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(12.dp)) .background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent) .glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2) .hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick) @@ -376,7 +804,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean = val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = (src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value) && enabled + val hot = rememberHot(src) && enabled Box( contentAlignment = Alignment.Center, modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp)) @@ -415,60 +843,73 @@ private fun Tag(text: String) { ) } +/** + * One installed choice: a radio for the one in use, its name and detail, and a trash at the right + * of the ones that can go. [progress] shows a download for it in place of the tag. + */ @Composable -private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, onSelect: () -> Unit, onDelete: () -> Unit) { +private fun ChoiceLine( + name: String, detail: String, selected: Boolean, removable: Boolean, + modifier: Modifier = Modifier, tag: String? = null, badge: String? = null, progress: Int? = null, + onSelect: () -> Unit, onDelete: () -> Unit = {}, +) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val hot = rememberHot(src) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() - .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (item.selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent), + .background(if (hot) pal.signal.copy(alpha = 0.14f) else if (selected) pal.signal.copy(alpha = 0.07f) else Color.Transparent), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier) .glideBorder(hot, ROW_SHAPE, pal.signal) - .hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onSelect) .controllerConfirm(onClick = onSelect) .padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (item.selected) pal.signal else colors.onSurfaceVariant, CircleShape)) { - if (item.selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape)) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).border(2.dp, if (selected) pal.signal else colors.onSurfaceVariant, CircleShape)) { + if (selected) Box(Modifier.size(9.dp).background(pal.signal, CircleShape)) } Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( - item.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (item.selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), + name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, + color = if (selected) pal.signal else colors.onBackground, modifier = Modifier.weight(1f, fill = false), ) - Tag(item.tag) + if (tag != null) Tag(tag) } - Text(item.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) + DetailLine(detail, badge) } - if (item.selected) Tag("IN USE") + // The radio says which is in use; a download in flight shows where a tag would. + if (progress != null) ProgressBit(progress) } // The trash column is always there (empty when the row can't be deleted) so rows line up. - if (!item.removable) Spacer(Modifier.padding(end = 6.dp).size(40.dp)) + if (!removable) Spacer(Modifier.padding(end = 6.dp).size(44.dp)) else { val delSrc = remember { MutableInteractionSource() } - val delHot = delSrc.collectIsFocusedAsState().value || delSrc.collectIsHoveredAsState().value + val delHot = rememberHot(delSrc) Box( contentAlignment = Alignment.Center, - modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE) + modifier = Modifier.padding(end = 6.dp).size(44.dp).clip(ROW_SHAPE) .background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent) .glideBorder(delHot, ROW_SHAPE, colors.error) .hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete) .controllerConfirm(onClick = onDelete), - ) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, item.name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } + ) { Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.common_delete_named, name), tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) } } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } +/** One thing not installed yet: its name and detail, and Get - its download shown in the row once started. */ @Composable -private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDownload: () -> Unit) { +private fun GetLine( + name: String, detail: String, progress: Int?, enabled: Boolean, + badge: String? = null, stage: String? = null, onCancel: (() -> Unit)? = null, onGet: () -> Unit, +) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current Row( @@ -476,22 +917,105 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp), ) { Column(Modifier.weight(1f)) { - Text(d.file.removeSuffix(".wcp"), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - "${d.release} · " + stringResource(R.string.common_size_mb, d.size / 1048576.0), fontSize = 13.sp, color = colors.onSurfaceVariant, - maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp), - ) + Text(name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + DetailLine(stage ?: detail, badge) } - if (progress == null) SmallButton(stringResource(R.string.common_download), enabled = enabled, accent = true, onClick = onDownload) - else Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { - Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) - if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth()) + if (progress != null) ProgressBit(progress) + else { + if (onCancel != null) FocusText(stringResource(R.string.proton_cancel_queued), pal.signal, onClick = onCancel) + SmallButton(stringResource(R.string.comp_get), enabled = enabled, accent = true, onClick = onGet) } } Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } -/** The Components page's tab for the GPU drivers, before the Proton components' own. */ -const val GPU_TAB = "gpu" +/** A row's second line, led by [badge] (Recommended) in green when there is one. */ +@Composable +private fun DetailLine(detail: String, badge: String?) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val text = androidx.compose.ui.text.buildAnnotatedString { + if (badge != null) { + pushStyle(androidx.compose.ui.text.SpanStyle(color = pal.good, fontWeight = FontWeight.SemiBold)) + append(badge.lowercase().replaceFirstChar { it.uppercase() }) + pop() + append(" · ") + } + append(detail) + } + Text(text, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 2.dp)) +} + +@Composable +private fun ProgressBit(progress: Int) { + val colors = MaterialTheme.colorScheme + Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { + Text(if (progress < 0) "…" else "$progress%", fontSize = 13.sp, color = colors.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + if (progress < 0) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + else LinearProgressIndicator(progress = { progress / 100f }, modifier = Modifier.fillMaxWidth()) + } +} + +/** + * The GPU and how far it is supported, as a chip: a green dot and " · tested", amber when it + * is not. A steps the device's full details out of it, with the copy for a bug report. + */ +@Composable +private fun DeviceChip(s: GpuDriversState) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val tint = if (s.supported && !s.unsupported) pal.good else AttentionAmber + val label = stringResource( + when { + s.unsupported -> R.string.comp_device_unsupported + s.supported -> R.string.comp_device_tested + else -> R.string.comp_device_untested + }, s.gpuName, + ) + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + var at by remember { mutableStateOf(null) } + val shape = RoundedCornerShape(99.dp) + val face: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(tint)) + Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + val open: () -> Unit = { + at?.let { from -> + Steps.ask(StepAsk(anchor = from, side = StepSide.Right, accent = pal.signal, items = 2, maxWidth = 420.dp, handle = face) { + Box(Modifier.stepItem(0)) { DeviceDetails(first) } + }) + } + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.onGloballyPositioned { at = it.boundsInRoot() }.heightIn(min = 44.dp).paneItem("gpu-device").clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = open) + .controllerConfirm(onClick = open) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { face() } +} + +/** What the Proton layer installs: the Protons DroidDeck can download, and one being installed. */ +class ProtonsState( + val rows: List = emptyList(), + val busyId: String? = null, + val stage: String? = null, + val percent: Int = -1, + val runtimeReady: Boolean = true, + val sessionRunning: Boolean = false, +) + +class ProtonsActions( + val onInstall: (String) -> Unit = {}, + val onCancel: (String) -> Unit = {}, + val onRemove: (String) -> Unit = {}, +) + +/** A Proton DroidDeck can install, and the folder it is installed as (null when it is not). */ +class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt new file mode 100644 index 000000000..9d52bdf50 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DesktopPage.kt @@ -0,0 +1,455 @@ +package com.droiddeck.launcher.ui + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.zIndex +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.runtime.UserApps +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.store.UserAppsState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// The Desktop tab in the Steam tab's shape: a tilted wall of little windows behind the word and +// one big Open desktop, with Play's lip and lifted sheet under it (Display, Controls, Session and +// Files). Under the hero the page scrolls to two shelves - the apps that run in the desktop, and +// what can be added - and the hero folds into a slim header as it goes. + +/** The desktop's own chips: its session settings, then Files. */ +private val DESKTOP_TABS = modeSheetTabs(steam = false) + SheetTab.FILES + +@Composable +internal fun DesktopPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier, onSelect: (String) -> Unit) { + val ctx = LocalContext.current + val density = LocalDensity.current + val narrow = LocalNarrowPane.current + val colors = MaterialTheme.colorScheme + LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } + // Flathub's installed apps sit with the others on the dock. + LaunchedEffect(s.ready) { com.droiddeck.launcher.store.StoreState.refresh(ctx) } + val installed = s.emulators.filter { it.installed } + val apps = UserAppsState.items + var adding by rememberSaveable { mutableStateOf(false) } + val mode = SessionService.MODE_DESKTOP + val tabs = DESKTOP_TABS.map { it to sheetTabLabel(it) } + val open = if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop) + // Opened from a card: the page floods out of it, its picture on the tile. + fun openFrom(at: Rect?, art: Any?, page: String) { + at?.let { PageOrigin.mark(Origin(it, 16f * density.density, art = art)) } + onSelect(page) + } + + // The Steam tab's shape, the whole pane: the dock of apps along the top of the wall, the word + // and Open desktop at the foot, and nothing under the settings sheet but the hero itself. + LiftHero( + key = "desktop", tabs = tabs, + onOpen = a.onDesktopSettings, onPrimary = a.onDesktop, + modifier = modifier.clipToBounds(), + start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, + background = { + DesktopWall(installed.map { it.iconRes } + apps.mapNotNull { it.icon }) + }, + scrim = { lifted -> + WallFade() + // Over the fades, so the cards read at full strength; gone as the sheet lifts, so + // nothing but the wall is behind it. + Box(Modifier.graphicsLayer { val v = lifted(); alpha = 1f - v; translationY = -12.dp.toPx() * v }) { + DesktopDock(s, a, installed, apps, openFrom = ::openFrom) + } + }, + words = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // The word at the wordmark's size. + Text( + stringResource(R.string.rail_desktop), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + fontWeight = FontWeight.Black, color = colors.onBackground, + ) + BusyChip(s) + } + }, + button = { click -> + // Enabled without a runtime or the desktop: the session's loading screen installs them first. + PrimaryButton(open, enabled = !s.busy, main = true, large = true, icon = Icons.Outlined.DesktopWindows, onClick = click) + }, + beside = { + // Flathub, whose apps run in the desktop: its storefront floods out of this button. + HeroIconButton(Icons.Outlined.Storefront, stringResource(R.string.flathub_name), FlathubTeal, "desktop:flathub") { at -> openFrom(at, null, "store") } + // Add your own app or game. + HeroIconButton(Icons.Filled.Add, stringResource(R.string.user_apps_add), null, "desktop:add") { if (!s.busy) adding = true } + }, + rows = { tab -> + if (tab == SheetTab.FILES) DesktopFiles(s, a) + else a.modeSheet(mode, tab, SheetExtras()) + }, + ) + if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } +} + +/** + * The dock along the top of the Desktop hero: what runs in the desktop (emulators installed, apps + * added, Flathub's installed apps), then the emulators that can be added - one + * row of cards that scrolls sideways, over the wall. It sinks back with the wall as the sheet lifts. + */ +@Composable +private fun DesktopDock( + s: FrontEndState, a: FrontEndActions, installed: List, apps: List, + openFrom: (Rect?, Any?, String) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + val available = s.emulators.filter { !it.installed } + val flatpaks = com.droiddeck.launcher.store.StoreState.installed + val keys = installed.map { "emu:${it.id}" } + apps.map { "user:${it.key}" } + flatpaks.map { "flatpak:${it.id}" } + // Where the last of your apps sits, for a new emulator's dot to land after it. + var lastAt by remember { mutableStateOf(null) } + var dockAt by remember { mutableStateOf(null) } + val seen = remember { mutableSetOf() } + val mounted = remember { booleanArrayOf(false) } + LaunchedEffect(Unit) { mounted[0] = true } + val scroll = rememberScrollState() + // Nothing yet (the emulators' catalog still on its way): no heading over an empty row. + if (keys.isEmpty() && available.isEmpty() && UserAppsState.working == null && UserAppsState.lastError == null) return + Column(Modifier.fillMaxWidth().padding(top = if (narrow) 14.dp else 22.dp)) { + Text( + stringResource(R.string.user_apps_section).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.6.sp, + color = colors.onSurfaceVariant, modifier = Modifier.padding(start = if (narrow) 20.dp else 44.dp, bottom = 8.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth().onGloballyPositioned { dockAt = it.boundsInRoot() } + .edgeFade(scroll).horizontalScroll(scroll).padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 2.dp, bottom = 4.dp), + ) { + keys.forEachIndexed { i, k -> + key(k) { + // A card that arrives after the dock is up (an emulator just installed) opens in. + val state = remember { MutableTransitionState(!mounted[0] || k in seen) }.apply { targetState = true } + LaunchedEffect(Unit) { seen += k } + AnimatedVisibility(state, enter = expandHorizontally(Motion.sp(0.75f)) + fadeIn(Motion.tw(300, 80))) { + Box(if (i == keys.lastIndex) Modifier.onGloballyPositioned { lastAt = it.boundsInRoot() } else Modifier) { + val e = installed.firstOrNull { "emu:${it.id}" == k } + val flatpak = flatpaks.firstOrNull { "flatpak:${it.id}" == k } + val app = apps.firstOrNull { "user:${it.key}" == k } + if (e != null) { + val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() } + DesktopCard( + k, e.name, + if (e.id == "retroarch") system else "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size), + art = e.iconRes, first = i == 0, + ) { at -> openFrom(at, e.iconRes, k) } + } else if (flatpak != null) DesktopCard( + k, flatpak.name, flatpak.summary ?: stringResource(R.string.flathub_name), + art = flatpak.icon ?: com.droiddeck.launcher.store.FlathubApi.iconUrl(flatpak.id), first = i == 0, + ) { at -> + // Its page on Flathub: open, update or remove it there. + com.droiddeck.launcher.store.StoreState.openRequest = flatpak.id + openFrom(at, flatpak.icon, "store") + } else if (app != null) DesktopCard(k, app.name, stringResource(app.kind.label()), art = app.icon, fallback = if (app.kind == UserApps.Kind.SCRIPT) Icons.Outlined.Terminal else Icons.Outlined.Apps, first = i == 0) { at -> + openFrom(at, app.icon, k) + } + } + } + } + } + // Between yours and what can be added: a thin rule. + if (keys.isNotEmpty()) Box(Modifier.padding(horizontal = 4.dp).width(1.dp).height(120.dp).background(pal.line2)) + available.forEach { e -> + key(e.id) { + val pkg = Library.packageId(e.id)?.let { id -> s.packages?.firstOrNull { it.id == id } } + val busyHere = pkg != null && s.packageBusyId == pkg.id + val canGet = pkg != null && !s.busy && s.packageBusyId == null && s.ready && !s.packageCatalogLoading && !s.sessionRunning + val system = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { c -> c.uppercase() } + DesktopCard( + "emu:${e.id}", e.name, if (busyHere) (s.packageStage ?: stringResource(R.string.store_installing)) else system, + art = e.iconRes, badge = if (busyHere) null else stringResource(R.string.comp_get), + progress = if (busyHere) s.packagePercent else null, first = keys.isEmpty() && e == available.first(), + ) { at -> + // Get installs it there and then, and a dot flies to where its card will be; + // when it cannot (no runtime, a session running), its page says why. + if (canGet && pkg != null && at != null) { + a.onInstallPackage(pkg.id) + val end = lastAt + val row = dockAt + Flights.fly(Flight(at, to = { + val r = row ?: return@Flight null + Offset((end?.right ?: r.left) + 70.dp.toPx(), r.center.y) + })) + } else openFrom(at, e.iconRes, "emu:${e.id}") + } + } + } + } + if (UserAppsState.working != null || UserAppsState.lastError != null) { + Box(Modifier.padding(start = if (narrow) 20.dp else 44.dp, end = 20.dp, top = 6.dp)) { UserAppsProgressLine() } + } + } +} + +/** + * A square button the big one's height beside it on the hero: an icon in [tint] (the palette's + * signal when null). [onClick] gets its bounds, for a page to flood out of. + */ +@Composable +private fun HeroIconButton(icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, tint: Color?, id: String, onClick: (Rect?) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.95f else if (hot) 1.04f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "heroIcon") + val shape = RoundedCornerShape(12.dp) + val accent = tint ?: pal.signal + var at by remember { mutableStateOf(null) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(56.dp).paneItem("btn:$id").onGloballyPositioned { at = it.boundsInRoot() } + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) accent.copy(alpha = 0.18f) else colors.surface.copy(alpha = 0.92f)) + .glideBorder(hot, shape, accent, pal.line2) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(at) } + .controllerConfirm { onClick(at) } + .semantics { contentDescription = description }, + ) { Icon(icon, null, tint = accent, modifier = Modifier.size(26.dp)) } +} + +/** + * The hero's wall: little windows tilted and drifting as Steam's capsules do, each a window with a + * bar along its foot and an emulator's or app's icon inside; blank windows when there are none. + */ +@Composable +private fun DesktopWall(icons: List) { + val pal = LocalPalette.current + val ground = remember(pal) { Brush.linearGradient(listOf(pal.surfaceVariant, pal.surface)) } + TiltedWall(icons.size, if (icons.isEmpty()) 75_000 else 50_000, capW = 132.dp, capH = 84.dp, gap = 14.dp, corner = 8.dp) { i -> + WallCapsule(ground, edge = pal.line2, icon = icons.getOrNull(i), bar = pal.background.copy(alpha = 0.7f)) + } +} + +/** + * One thing that runs in the desktop: its icon over a blurred, darkened copy of itself, a status on + * the art (Get, or how far an install is), and under it its name on up to two lines and what it + * is. [onClick] gets the art's bounds, for a page to flood out of or a dot to fly from. + */ +@Composable +private fun DesktopCard( + key: String, name: String, detail: String, art: Any?, + fallback: androidx.compose.ui.graphics.vector.ImageVector = Icons.Outlined.Apps, + first: Boolean = false, badge: String? = null, progress: Int? = null, accent: Boolean = false, tint: Color? = null, + onClick: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "deskCard") + val shape = RoundedCornerShape(16.dp) + var artAt by remember { mutableStateOf(null) } + val width = if (LocalNarrowPane.current) 116.dp else 132.dp + val artH = width * 0.62f + val ground = tint?.copy(alpha = 0.25f) ?: if (accent) pal.signal.copy(alpha = 0.12f) else colors.surfaceVariant + Column( + Modifier.width(width).paneItem("tile:desktop:$key").then(if (first) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button) { onClick(artAt) } + .controllerConfirm { onClick(artAt) } + .semantics { contentDescription = name }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { artAt = it.boundsInRoot() } + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(ground), + ) { + when (art) { + is Int -> { + Image( + painterResource(art), null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + Image(painterResource(art), null, modifier = Modifier.size(artH * 0.6f)) + } + null -> Icon(fallback, null, tint = tint ?: if (accent) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.42f)) + else -> { + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.5f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.6f)) + } + } + Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { + if (progress != null) Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) { + if (progress >= 0) CircularProgressIndicator(progress = { progress / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp)) + } + else if (badge != null) Text( + badge, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(pal.signal).padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) { + Text(name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(detail, fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp)) + } + } +} + +/** + * The desktop sheet's Files chip: the file manager and the ROMs folder (both from Setup's old + * tools), how much the desktop takes, and Uninstall - its only home - which asks in red first. + */ +@Composable +private fun DesktopFiles(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + SettingsGroup(stringResource(R.string.setup_tool_files)) { + SettingsRow(stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint)) { + SecondaryButton(stringResource(R.string.store_open), compact = true, onClick = a.onFiles) + } + SettingsRow(stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint)) { + SecondaryButton(stringResource(R.string.setup_choose), compact = true, onClick = a.onRoms) + } + } + if (s.desktopPresent) SettingsGroup(stringResource(R.string.content_linux_desktop)) { + val size by produceState(null) { value = withContext(Dispatchers.IO) { runCatching { com.droiddeck.launcher.runtime.DesktopCatalog.desktopSize(ctx) }.getOrNull() } } + SettingsRow(stringResource(R.string.desktop_size), size?.let { FileUtils.sizeToString(it) } ?: "…") {} + SettingsRow(stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text)) { + UninstallDesktop(enabled = !s.runtimeActionsBlocked, onUninstall = a.onRemoveDesktop) + } + } +} + +/** Uninstall the desktop: are you sure steps out of it in red, Keep first and focused. */ +@Composable +private fun UninstallDesktop(enabled: Boolean, onUninstall: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.common_uninstall) + val title = stringResource(R.string.desktop_uninstall_title) + val note = stringResource(R.string.desktop_remove_text) + val keep = stringResource(R.string.flathub_keep) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled, compact = true) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3, + handle = { Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(title) + Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth().stepItem(2)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() } + } + }) + } + } +} + +/** The add or remove under way, or why the last one failed. */ +@Composable +private fun UserAppsProgressLine() { + val colors = MaterialTheme.colorScheme + val working = UserAppsState.working + Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + if (working != null) { + val stage = UserAppsState.stage ?: stringResource(R.string.user_apps_starting) + val percent = UserAppsState.percent + Text( + if (percent >= 0) stringResource(R.string.user_apps_progress_percent, working, stage, percent) else stringResource(R.string.user_apps_progress, working, stage), + fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + if (percent >= 0) androidx.compose.material3.LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) + else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) + } else UserAppsState.lastError?.let { Note(it) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt index 64e1bfc60..992e337a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndArt.kt @@ -56,9 +56,21 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import coil.compose.AsyncImage import java.io.File +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned // Cover art: posters, the art grid and its tiles. +/** Where each art tile sits, root px, by its key: a page opened from one floods out of it. */ +internal object TileBounds { + val at = HashMap() + + /** The page about to open came from tile [key] (its art or icon for the flood), if it is on screen. */ + fun mark(key: String, art: Any?, corner: Float) { + at[key]?.let { PageOrigin.mark(Origin(it, corner, art = art)) } + } +} + internal class Tile( val title: String, val sub: String?, val art: File?, val key: String, val iconRes: Int? = null, val dim: Boolean = false, val iconBitmap: Bitmap? = null, @@ -102,6 +114,7 @@ internal fun ArtGrid(tiles: List, wide: Boolean = false) { val src = remember { MutableInteractionSource() } val hot = rememberHot(src) val track = Modifier.paneItem("tile:" + t.key).then(if (t === tiles.first()) Modifier.firstTile() else Modifier) + .onGloballyPositioned { TileBounds.at[t.key] = it.boundsInRoot() } Box(modifier = Modifier.width(tileWidth).zIndex(if (hot) 1f else 0f)) { GameTile(t, wide, square, src, hot, track) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index 925b0a883..764967860 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -76,6 +76,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -124,15 +126,30 @@ internal fun Pane( val paneAt = remember { arrayOf(Offset.Zero) } val livePage by rememberUpdatedState(s.pageKey) // Picking another game changes the detail beside the list, not the whole page. - val target = if (page != null && s.pageKey != null) s.pageKey else if (selected.startsWith("app:")) "games" else selected + val railContent = if (selected.startsWith("app:")) "games" else selected + val target = if (page != null && s.pageKey != null) s.pageKey else railContent // The game the Games page shows. It must outlive the selection: while the page animates out, // drawing it with what is selected now (another rail item) turned it into a copy of that page. val gameShown = remember { arrayOf("games") } if (selected == "games" || selected.startsWith("app:")) gameShown[0] = selected + // What the pane showed before this change: the content a page opened over belongs to it. + val before = remember { arrayOf(target) } if (page != null && s.pageKey != null) { pages[s.pageKey] = page - if (s.pageKey !in origins) PageOrigin.take()?.let { origins[s.pageKey] = it.translate(-paneAt[0]) } + if (s.pageKey !in origins) PageOrigin.take()?.let { o -> + // A hop's page came out of its own rail item, and is that item's content. + origins[s.pageKey] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(railContent) else it } + } + } else if (target != before[0] && target !in origins) { + // A section's own page opened from a control in it (an emulator's card, a ROM's) floods + // out of that control and belongs to what was showing; a hop's comes out of its rail item. + PageOrigin.take()?.let { o -> origins[target] = inPane(o.translate(-paneAt[0])).let { if (it.owner == null) it.ownedBy(before[0]) else it } } } + before[0] = target + val targetNow by rememberUpdatedState(target) + // A page leaving through a hop's link is not coming back to its control. + val leave = if (Hops.linking) PageLeave.Link else PageLeave.Ordinary + LaunchedEffect(target) { Hops.linking = false } AnimatedContent( targetState = target, modifier = Modifier.onGloballyPositioned { paneAt[0] = it.positionInRoot() }, @@ -143,9 +160,14 @@ internal fun Pane( .togetherWith(fadeOut(Motion.tw(320, 80)) + scaleOut(Motion.tw(480), targetScale = 0.95f)) .apply { targetContentZIndex = 1f } // Back out of it: the flood draws back into the control as the pane comes forward again. - initialState in origins -> (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f)) - .togetherWith(ExitTransition.None) - .apply { targetContentZIndex = -1f } + initialState in origins && pageExit(origins[initialState]?.owner, targetState, leave) == PageExit.Drain -> + (fadeIn(Motion.tw(300, 100)) + scaleIn(Motion.tw(460, 40), initialScale = 0.95f)) + .togetherWith(ExitTransition.None) + .apply { targetContentZIndex = -1f } + // Away to something else: the control is not coming back, so the page sinks with the pane. + initialState in origins -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) + .togetherWith(fadeOut(Motion.tw(170)) + scaleOut(Motion.tw(170), targetScale = 0.95f)) + .apply { targetContentZIndex = 1f } else -> (fadeIn(Motion.tw(300, 80)) + slideInVertically(Motion.tw(420, 80)) { it / 24 }) .togetherWith(fadeOut(Motion.tw(170)) + slideOutVertically(Motion.tw(170)) { -it / 40 }) .apply { targetContentZIndex = 1f } @@ -153,26 +175,51 @@ internal fun Pane( }, label = "pane", ) { key -> - val shown = if (page != null && key == s.pageKey) page else pages[key] - if (shown != null) { - DisposableEffect(key) { onDispose { if (key != livePage) { pages.remove(key); origins.remove(key) } } } + val drawn = (if (page != null && key == s.pageKey) page else pages[key]) + ?: { Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) } + // A hop leaving this content: it sinks back as the dot leaves it (and comes forward + // again, freshly drawn, when the hop is undone). + val hopSink by androidx.compose.animation.core.animateFloatAsState( + if (Hops.sinking == key) 1f else 0f, Motion.tw(260), label = "hopSink", + ) + val shown: @Composable () -> Unit = { + Box(Modifier.fillMaxSize().graphicsLayer { val k = 1f - 0.05f * hopSink; scaleX = k; scaleY = k; alpha = 1f - 0.35f * hopSink }) { drawn() } + } + run { + DisposableEffect(key) { onDispose { if (key != livePage && key != targetNow) { pages.remove(key); origins.remove(key) } } } val from = origins[key] if (from == null) shown() else { + // The control's own picture (a card's art, an emulator's icon) on the tile as it grows. + val art = when (val m = from.art) { + null -> null + is Int -> androidx.compose.ui.res.painterResource(m) + else -> coil.compose.rememberAsyncImagePainter(m) + } + val leaving = transition.targetState == EnterExitState.PostExit + // Settled as the page starts to leave, from what is coming in, not from where it came in. + val drains = remember(leaving) { !leaving || pageExit(from.owner, target, leave) == PageExit.Drain } // Keeps a leaving page on screen while its flood draws back into the control. transition.animateFloat( - transitionSpec = { if (targetState == EnterExitState.PostExit) Motion.tw(PAGE_RETURN_MS) else snap() }, + transitionSpec = { if (targetState == EnterExitState.PostExit && drains) Motion.tw(PAGE_RETURN_MS) else snap() }, label = "pageReturn", ) { if (it == EnterExitState.PostExit) 1f else 0f } - PageFlood(from, leaving = transition.targetState == EnterExitState.PostExit) { shown() } + PageFlood(from, leaving = leaving, art = art, sinks = !drains) { shown() } } } - else Content(s, if (key == "games") gameShown[0] else key, a, Modifier.fillMaxSize(), onSelect, onAndroidAppClick, onOpenDeveloperOptions, onRequestWirelessAdb) } } } } +/** + * An origin left of the pane (a rail item a hop flew to) starts half inside it, at the item's + * height: the page swells out of the pane's edge beside the item. Wholly outside, its tile was a + * bar that raced across from the far edge. + */ +internal fun inPane(o: Origin): Origin = + if (o.bounds.right > 0f) o else o.translate(Offset(-o.bounds.right + o.bounds.width / 2f, 0f)) + @Composable private fun Backdrop(art: File?) { val colors = MaterialTheme.colorScheme @@ -207,6 +254,7 @@ private fun Content( val colors = MaterialTheme.colorScheme val detailPosterWidth = if (LocalConfiguration.current.screenHeightDp < 600) 72.dp else 120.dp val narrow = LocalNarrowPane.current + val density = androidx.compose.ui.platform.LocalDensity.current.density val padH = if (narrow) 16.dp else 22.dp val padV = if (narrow) 12.dp else 18.dp // Setup scrolls inside itself, under its tabs. @@ -226,12 +274,12 @@ private fun Content( return } // The Store scrolls and navigates inside itself (its tabs and an app's page). - if (selected == "store" && s.storeEnabled) { + if (selected == "store") { StorePage(s, a, modifier) return } // Stores (GOG, Epic, Amazon) takes the whole pane: chips, shelves and a game's page inside it. - if (selected == "stores" && s.gameStoresEnabled) { + if (selected == "stores") { StoresPage(s, a, modifier) return } @@ -240,6 +288,11 @@ private fun Content( GamesPage(s, a, selected, onSelect, modifier) return } + // Desktop is the Steam tab's shape: a hero with its lifted sheet, and shelves under it. + if (selected == "desktop") { + DesktopPage(s, a, modifier, onSelect) + return + } // Every other page scrolls as one - header, hero and grid - so a short screen reaches the grid // instead of showing a sliver of it under a fixed hero. A focused tile scrolls itself into view. Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = padH, vertical = padV)) { @@ -260,37 +313,6 @@ private fun Content( }) } } - selected == "desktop" -> { - val installed = s.emulators.filter { it.installed } - val available = s.emulators.filter { !it.installed } - val ctx = LocalContext.current - LaunchedEffect(Unit) { UserAppsState.refresh(ctx) } - val apps = UserAppsState.items - var adding by rememberSaveable { mutableStateOf(false) } - Rise(0) { - PageHeader(stringResource(R.string.drawer_desktop)) { - if (s.desktopInstalled) Chip(stringResource(R.string.content_desktop_installed), ok = true) else Chip(stringResource(R.string.content_desktop_first_open), ok = false) - } - } - Rise(2) { DesktopCard(s, a) } - // Add closes the last grid: the installable emulators, or the installed ones once none are left. - val ready = installed.map { GridItem.Emu(it) } + apps.map { GridItem.User(it) } - val addAfterReady = available.isEmpty() - if (ready.isNotEmpty() || addAfterReady) { - Rise(3) { SectionTitle(stringResource(R.string.user_apps_section), ready.size.takeIf { it > 0 }?.toString()) } - Rise(4) { - LauncherGrid(if (addAfterReady) ready + GridItem.Add else ready, first = true, onSelect = onSelect, onAdd = { if (!s.busy) adding = true }) - } - } - if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(5) { UserAppsProgress() } - if (!addAfterReady) { - Rise(5) { SectionTitle(stringResource(R.string.content_available), available.size.toString()) } - Rise(6) { - LauncherGrid(available.map { GridItem.Emu(it) } + GridItem.Add, first = ready.isEmpty(), onSelect = onSelect, onAdd = { if (!s.busy) adding = true }) - } - } - if (adding && !s.busy) AddAppDialog(s.ready, onDismiss = { adding = false }) { request, label -> UserAppsState.add(ctx, request, label) } - } selected.startsWith("user:") -> { val app = UserAppsState.items.firstOrNull { "user:${it.key}" == selected } if (app == null) Note(stringResource(R.string.user_apps_gone)) else UserAppPage(app, s, a, onSelect) @@ -300,21 +322,26 @@ private fun Content( if (e == null) Note(stringResource(R.string.content_not_installed)) else { val pkgId = Library.packageId(e.id) val pkg = pkgId?.let { id -> s.packages?.firstOrNull { it.id == id } } - Rise(0) { - BackLink(stringResource(R.string.drawer_desktop)) { onSelect("desktop") } - } + // Out of its card on Desktop, with the card's icon; B draws it back in. Rise(1) { Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(top = 4.dp, bottom = 12.dp), ) { - Image(painterResource(e.iconRes), null, modifier = Modifier.size(52.dp)) + Image(painterResource(e.iconRes), null, modifier = Modifier.size(64.dp)) Column { - Text(e.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(e.name, fontSize = if (narrow) 24.sp else 30.sp, fontWeight = FontWeight.Black, color = colors.onBackground) Text( systemLabel(e).let { if (e.installed) it else stringResource(R.string.content_not_installed_suffix, it) }, fontSize = 14.sp, color = colors.onSurfaceVariant, ) + if (e.installed) Text( + listOfNotNull( + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size).takeIf { e.id != "retroarch" || e.games.isNotEmpty() }, + s.romsDir, + ).joinToString(" · "), + fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) } } } @@ -339,7 +366,24 @@ private fun Content( ) } else Rise(5, Modifier.fillMaxWidth()) { - ArtGrid(e.games.mapIndexed { index, g -> Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } }, wide = e.games.none { it.art != null }) + Column { + // RetroArch's games come from many systems: chips filter them, one + // selection gliding between them. + val systems = remember(e.games) { e.games.map { romSystem(it) }.distinct() } + var system by rememberSaveable(e.id) { mutableStateOf(null) } + if (systems.size > 1) Box(Modifier.padding(top = 10.dp)) { + val labels = listOf(stringResource(R.string.desktop_all_systems)) + systems + SheetChips(labels, if (system == null) 0 else systems.indexOf(system) + 1) { i -> system = if (i == 0) null else systems[i - 1] } + } + val shown = e.games.withIndex().filter { system == null || romSystem(it.value) == system } + ArtGrid(shown.map { (index, g) -> + val key = "rom:${e.id}:$index" + Tile(g.name, if (g.art != null) stringResource(R.string.content_rom_installed) else g.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, g.art, key, e.iconRes) { + TileBounds.mark(key, g.art ?: e.iconRes, 12f * density) + onSelect(key) + } + }, wide = e.games.none { it.art != null }) + } } } else { if (pkg != null) Rise(2) { @@ -381,12 +425,7 @@ private fun Content( val pair = romFor(s, selected) if (pair == null) Note(stringResource(R.string.content_rom_gone)) else { val (e, g) = pair - Rise(0) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - BackLink(e.name) { onSelect("emu:${e.id}") } - Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) - } - } + Rise(0) { Eyebrow(stringResource(R.string.content_desktop_system, systemLabel(e))) } Rise(1) { Title(g.name) } Rise(2) { Row { @@ -408,7 +447,10 @@ private fun Content( Rise(4, Modifier.fillMaxWidth()) { ArtGrid(others.map { x -> val index = e.games.indexOf(x) - Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { onSelect("rom:${e.id}:$index") } + Tile(x.name, if (x.art != null) stringResource(R.string.content_rom_installed) else x.hostPath.extension.uppercase().ifEmpty { stringResource(R.string.content_rom_folder) }, x.art, "rom:${e.id}:$index", e.iconRes) { + TileBounds.mark("rom:${e.id}:$index", x.art ?: e.iconRes, 12f * density) + onSelect("rom:${e.id}:$index") + } }, wide = others.none { it.art != null }) } } @@ -419,122 +461,6 @@ private fun Content( } } -/** The Linux desktop itself, above the emulators that run in it. */ -@Composable -private fun DesktopCard(s: FrontEndState, a: FrontEndActions) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var confirmRemove by rememberSaveable { mutableStateOf(false) } - if (confirmRemove) ConfirmDialog( - stringResource(R.string.desktop_remove_title), stringResource(R.string.desktop_remove_text), - stringResource(R.string.common_uninstall), a.onRemoveDesktop, { confirmRemove = false }, - ) - val actions: @Composable () -> Unit = { - Actions { - // Enabled without a runtime or the desktop: the session's loading screen installs them first. - PrimaryButton(if (s.desktopInstalled) stringResource(R.string.content_open_desktop) else stringResource(R.string.content_install_open_desktop), enabled = !s.busy, main = true, onClick = a.onDesktop) - Cog(onClick = a.onDesktopSettings) - if (s.desktopPresent) SecondaryButton(stringResource(R.string.common_uninstall), - enabled = !s.runtimeActionsBlocked) { confirmRemove = true } - BusyChip(s) - } - } - // The button goes under the title unless the card is wide enough for both side by side: in some - // languages its label alone takes half a small phone's pane. - BoxWithConstraints(Modifier.fillMaxWidth()) { - val narrow = LocalNarrowPane.current || maxWidth < DesktopCardInlineWidth - Column( - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(52.dp).clip(Shape14).background(colors.surfaceVariant).border(1.dp, pal.line2, Shape14), - ) { Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(26.dp)) } - Column(modifier = Modifier.weight(1f)) { - Text(stringResource(R.string.content_linux_desktop), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text(stringResource(R.string.content_linux_desktop_hint), fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - if (!narrow) actions() - } - if (narrow) actions() - } - } -} - -/** How wide the desktop card must be to keep its button beside the title. */ -private val DesktopCardInlineWidth = 640.dp - -/** What the Desktop page's grids hold: emulators, the user's own apps, and Add at the end. */ -private sealed class GridItem(val key: String) { - class Emu(val e: Library.Emulator) : GridItem("emu:${e.id}") - class User(val app: UserApps.App) : GridItem("user:${app.key}") - data object Add : GridItem("add") -} - -/** Wide tiles - three across, two on a small phone, or a list on a narrow page. */ -@Composable -private fun LauncherGrid(items: List, first: Boolean, onSelect: (String) -> Unit, onAdd: () -> Unit = {}) { - BoxWithConstraints(Modifier.fillMaxWidth()) { - // A tile holds an icon, a name and an Install button: one much under 230dp wide leaves the - // name no room at all. - val columns = when { - LocalNarrowPane.current -> 1 - maxWidth < 720.dp -> 2 - else -> 3 - } - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { - items.chunked(columns).forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - row.forEachIndexed { i, item -> - key(item.key) { - val m = Modifier.weight(1f).fillMaxHeight() - val isFirst = first && r == 0 && i == 0 - when (item) { - is GridItem.Emu -> EmulatorTile(item.e, m, isFirst) { onSelect(item.key) } - is GridItem.User -> UserAppTile(item.app, m, isFirst) { onSelect(item.key) } - GridItem.Add -> AddTile(m, isFirst, onAdd) - } - } - } - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } - } -} - -/** A grid tile's frame: the emulator tiles' look, focus glide and press. */ -@Composable -private fun TileFrame(id: String, modifier: Modifier, isFirst: Boolean, filled: Boolean, onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "tileScale") - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("tile:$id").then(if (isFirst) Modifier.firstTile() else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else if (filled) colors.surface else Color.Transparent) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { content() } -} - -@Composable -private fun TileText(title: String, detail: String, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - Column(modifier = modifier) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} - internal fun UserApps.Kind.label() = when (this) { UserApps.Kind.SCRIPT -> R.string.user_apps_kind_script UserApps.Kind.APPIMAGE -> R.string.user_apps_kind_appimage @@ -556,26 +482,6 @@ private fun UserAppIcon(app: UserApps.App, size: Int) { } } -@Composable -private fun UserAppTile(app: UserApps.App, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) = - TileFrame("user:${app.key}", modifier, isFirst, filled = true, onClick = onClick) { - UserAppIcon(app, 44) - TileText(app.name, stringResource(app.kind.label()), Modifier.weight(1f)) - } - -/** The grid's last tile: opens the Add dialog. */ -@Composable -private fun AddTile(modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val pal = LocalPalette.current - TileFrame("add", modifier, isFirst, filled = false, onClick = onClick) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(44.dp).clip(Shape12).background(pal.signal.copy(alpha = 0.12f)).border(1.dp, pal.signal.copy(alpha = 0.35f), Shape12), - ) { Icon(Icons.Filled.Add, null, tint = pal.signal, modifier = Modifier.size(24.dp)) } - TileText(stringResource(R.string.user_apps_add), stringResource(R.string.user_apps_add_detail), Modifier.weight(1f)) - } -} - /** The add or remove under way, or why the last one failed. */ @Composable private fun UserAppsProgress() { @@ -595,20 +501,17 @@ private fun UserAppsProgress() { } } -/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (pressed twice). */ +/** An added app's page: open it, change its name and icon, update one added from GitHub, or remove it (asked first). */ @Composable private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, onSelect: (String) -> Unit) { val colors = MaterialTheme.colorScheme val ctx = LocalContext.current val narrow = LocalNarrowPane.current - var confirm by remember(app.key) { mutableStateOf(false) } var editing by rememberSaveable(app.key) { mutableStateOf(false) } val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF val fexReady by produceState(null, app.key, x86, s.sessionRunning) { value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true } - LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } } - Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } } Rise(1) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -631,12 +534,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, BusyChip(s) SecondaryButton(stringResource(R.string.user_apps_edit), enabled = UserAppsState.working == null) { editing = true } if (app.repo != null) UpdateButton(app, s) - SecondaryButton( - stringResource(if (confirm) R.string.user_apps_remove_confirm else R.string.user_apps_remove), - enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null, - ) { - if (!confirm) confirm = true - else { confirm = false; UserAppsState.remove(ctx, app); onSelect("desktop") } + RemoveAppButton(app, enabled = !s.busy && !s.sessionRunning && UserAppsState.working == null) { + UserAppsState.remove(ctx, app); onSelect("desktop") } if (s.sessionRunning) ActionChip(stringResource(R.string.user_apps_stop_session), ok = false) } @@ -657,6 +556,31 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, } } +/** Remove, which asks first: are you sure steps out of it in red, Keep first and focused. */ +@Composable +private fun RemoveAppButton(app: UserApps.App, enabled: Boolean, onRemove: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.user_apps_remove) + val title = stringResource(R.string.flathub_remove_title, app.name) + val keep = stringResource(R.string.flathub_keep) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 2, + handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(title) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(1)) { + StepChoice(keep, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onRemove() } + } + }) + } + } +} + /** Looks up a newer release of an app added from GitHub, then installs it. */ @Composable private fun UpdateButton(app: UserApps.App, s: FrontEndState) { @@ -688,21 +612,5 @@ private fun UpdateButton(app: UserApps.App, s: FrontEndState) { private fun systemLabel(e: Library.Emulator): String = if (e.systemRes != 0) stringResource(e.systemRes) else e.system.replaceFirstChar { it.uppercase() } -@Composable -private fun EmulatorTile(e: Library.Emulator, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val system = systemLabel(e) - val detail = if (e.installed && e.id != "retroarch") "$system · " + pluralStringResource(R.plurals.mode_added_count, e.games.size, e.games.size) else system - TileFrame("emu:${e.id}", modifier, isFirst, filled = e.installed, onClick = onClick) { - Image(painterResource(e.iconRes), contentDescription = null, modifier = Modifier.size(if (e.installed) 44.dp else 36.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(e.name, fontSize = if (e.installed) 15.sp else 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, fontSize = if (e.installed) 13.sp else 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - if (!e.installed) Text( - stringResource(R.string.setup_install), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).border(1.dp, pal.line2, RoundedCornerShape(8.dp)).padding(horizontal = 10.dp, vertical = 6.dp), - ) - } -} +/** The system a ROM belongs to: the folder it sits in under the ROMs folder. */ +private fun romSystem(rom: Library.Rom): String = rom.hostPath.parentFile?.name.orEmpty() diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt index e5f91a76e..16290a1ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt @@ -77,6 +77,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import com.droiddeck.launcher.core.PhantomProcessLimit // The front end's side rail: the page list and the resume entry under it. @@ -103,27 +106,53 @@ internal fun SideRail( modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp), ) { // Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls. - val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0) + val count = 7 + (if (s.isHomeApp) 1 else 0) BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp) + // A hop in flight moves the selection ahead of the page it is bringing. + val shown = Hops.railPreview ?: selected + val keys = buildList { + add("steam"); add("games") + add("stores") + add("desktop") + add("components") + if (s.isHomeApp) add("android-apps") + add("setup"); add("updates") + } + // Where each item sits in the column, for the selection that glides between them. + val bounds = remember { androidx.compose.runtime.mutableStateMapOf() } + val tint = androidx.compose.runtime.rememberUpdatedState(LocalPalette.current.signal) + var railAt by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).onGloballyPositioned { railAt = it.boundsInRoot().topLeft }) { + // One selection for the whole rail, under the items: it glides from item to item as the + // chips' does, its leading edge first, and a jump of more than one item goes as a drop. + if (shown in keys) ChipGlide(keys.indexOf(shown), bounds[shown]?.translate(-railAt), tint, GlideAxis.Vertical, corner = 14.dp, fill = 0.14f, outline = 0f) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), ) { - RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } + val place: (String) -> Modifier = { key -> + // In root px: an item that comes and goes sits in its own wrapper, not the column. + Modifier.onGloballyPositioned { + bounds[key] = it.boundsInRoot() + Hops.railBounds[key] = it.boundsInRoot() + } + } + RailItem(stringResource(R.string.rail_steam), Icons.Outlined.SportsEsports, "steam", shown == "steam", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("steam") }) { onSelect("steam") } // Always there, so the items below it never move; an empty library says how to fill it. - RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") } + RailItem(stringResource(R.string.rail_games), Icons.Outlined.VideoLibrary, "games", shown == "games", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("games") }) { onSelect("games") } // The game stores, right under Games: what they install lands there. Its badge counts // the downloads in flight, so a download started here is never out of sight. - if (s.gameStoresEnabled) RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", selected == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, onFocus = { onFocusSelect("stores") }) { onSelect("stores") } - RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } - if (s.storeEnabled) RailItem(stringResource(R.string.rail_store), Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") } - RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") } + // A section switched on in Setup comes in as the rail's own entrance; off, it folds away. + RailItem(stringResource(R.string.rail_stores), Icons.Outlined.LocalMall, "stores", shown == "stores", compact, iconOnly, fit, count = s.storeDownloadsActive, place = place, onFocus = { onFocusSelect("stores") }) { onSelect("stores") } + RailItem(stringResource(R.string.rail_desktop), Icons.Outlined.DesktopWindows, "desktop", shown == "desktop", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") } + RailItem(stringResource(R.string.rail_components), Icons.Outlined.Layers, "components", shown == "components", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("components") }) { onSelect("components") } // Home mode's extra section goes last, so it shifts nothing above it. - if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } - RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } - RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") } + if (s.isHomeApp) RailItem(stringResource(R.string.rail_apps), Icons.Outlined.Apps, "android-apps", shown == "android-apps", compact, iconOnly, fit, place = place, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") } + RailItem(stringResource(R.string.rail_setup), Icons.Outlined.Tune, "setup", shown == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, place = place, onFocus = { onFocusSelect("setup") }) { onSelect("setup") } + RailItem(stringResource(R.string.rail_updates), Icons.Outlined.SystemUpdate, "updates", shown == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, place = place, onFocus = { onFocusSelect("updates") }) { onSelect("updates") } + } } } var lastRunning by remember { mutableStateOf("") } @@ -142,6 +171,8 @@ private fun RailItem( badge: Boolean = false, /** A number on the item (downloads in flight); 0 shows nothing. */ count: Int = 0, + /** Reports where the item sits, for the rail's gliding selection and a hop's dot. */ + place: (String) -> Modifier = { Modifier }, onFocus: () -> Unit = {}, onClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -162,8 +193,9 @@ private fun RailItem( if (current) pal.signal else if (focused || hovered) colors.onBackground else colors.onSurfaceVariant, Motion.tw(220), label = "railFg", ) + // The selected look is the rail's glide under the item, so the item draws only hover here. val fill by animateColorAsState( - if (current) pal.signal.copy(alpha = 0.14f) else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, + if (current) Color.Transparent else if (focused || hovered) Color.White.copy(alpha = 0.05f) else Color.Transparent, Motion.tw(220), label = "railFill", ) val scale by animateFloatAsState(if (pressed) 0.95f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "railScale") @@ -172,6 +204,9 @@ private fun RailItem( modifier = Modifier .then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier) .testTag("rail-$key") + .then(place(key)) + // Pops as a hop's dot lands on it. + .landingPop(Hops.railLanded[key] ?: 0) .graphicsLayer { scaleX = scale; scaleY = scale } .size(width = if (iconOnly) 52.dp else 80.dp, height = height) .clip(Shape14) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 1c2e50dec..c148362eb 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -70,6 +70,8 @@ import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.input.InputMode import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import com.droiddeck.launcher.HomeApp import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.gpu.FrameGen @@ -132,10 +134,6 @@ class FrontEndState( val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, val launcherFullscreen: Boolean = true, val animationsEnabled: Boolean = true, - /** The Flathub Store, a beta the user turns on in Setup. */ - val storeEnabled: Boolean = false, - /** The Stores section (GOG, Epic Games, Amazon Games), turned on in Setup. */ - val gameStoresEnabled: Boolean = false, /** Setup › Stores: the download engine's speed tier (stores/download/StoreDownloadTier ids). */ val gameStoresSpeedTier: String = "fast", /** Setup › Stores: the tab a signed-in store opens on (SessionPrefs.STORES_OPEN_LIBRARY / STORE). */ @@ -178,8 +176,12 @@ class FrontEndActions( val onUserApp: (com.droiddeck.launcher.runtime.UserApps.App) -> Unit = {}, val onRom: (Library.Rom) -> Unit, val onResume: () -> Unit, + /** Reads Steam's session settings for its lifted sheet (under Play). */ val onSteamSettings: () -> Unit, + /** Reads the desktop's session settings for its lifted sheet (under Open desktop). */ val onDesktopSettings: () -> Unit, + /** A lifted sheet's rows for one tab of [mode]'s settings, as last read. */ + val modeSheet: @Composable (mode: String, tab: String, extras: SheetExtras) -> Unit = { _, _, _ -> }, val onRemoveDesktop: () -> Unit = {}, val onInstallPackage: (String) -> Unit, val onRemovePackage: (String) -> Unit, @@ -189,6 +191,8 @@ class FrontEndActions( val onProtons: () -> Unit, /** The Components page: FEX / DXVK / VKD3D-Proton per Proton. */ val onComponents: (focusContent: Boolean) -> Unit, + /** The Components page on one layer ([GPU_TAB], a component id): a hop's destination. */ + val onComponentsTab: (String) -> Unit = {}, /** A game page's Manage saves: import a save zip into this game, or export its saves in a layout. */ val onSaveImport: (Library.SteamGame) -> Unit = {}, val onSaveExport: (Library.SteamGame, com.droiddeck.launcher.session.GameSaves.Layout) -> Unit = { _, _ -> }, @@ -208,8 +212,6 @@ class FrontEndActions( val onOrientation: (Int) -> Unit = {}, val onLauncherFullscreen: (Boolean) -> Unit = {}, val onAnimationsEnabled: (Boolean) -> Unit = {}, - val onStoreEnabled: (Boolean) -> Unit = {}, - val onGameStoresEnabled: (Boolean) -> Unit = {}, val onGameStoresSpeedTier: (String) -> Unit = {}, val onStoresOpenTab: (String) -> Unit = {}, val onNavigated: () -> Unit = {}, @@ -382,6 +384,18 @@ internal suspend fun focusWithinFrames(done: () -> Boolean, target: () -> FocusR } } +/** + * Asks [target] for focus on the next frames until a request lands (a control can take focus only + * once it is laid out), for at most half a second; then leaves focus alone, so a press right after + * is never pulled back. + */ +internal suspend fun focusSoon(target: () -> FocusRequester) { + repeat(30) { + androidx.compose.runtime.withFrameNanos { } + if (runCatching { target().requestFocus() }.isSuccess) return + } +} + @Composable fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)? = null) { val frontFocus = remember { FrontFocus() } @@ -423,19 +437,27 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } } - BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { a.onPageBack() } - // Back (and B) from a ROM, an emulator or an added app steps out one level, as its "‹" link - // does, instead of leaving the app: a ROM -> its emulator, the others -> Desktop. + // A page reached by a hop goes back the way it came; any other page just closes. + val pageBack = { if (!Hops.back(a.onPageBack)) a.onPageBack() } + BackHandler(enabled = !processSettingsPageVisible && s.pageKey != null && page != null) { pageBack() } + // Left any other way, the hop's page has no way back to keep. + LaunchedEffect(s.pageKey, selected) { Hops.forget(s.pageKey ?: selected) } + // A section reached by a hop (Desktop's Flathub card to the Store) goes back the way it came. + BackHandler(enabled = !processSettingsPageVisible && s.pageKey == null && Hops.origin?.page == selected) { + val rail = Hops.origin?.rail ?: return@BackHandler + Hops.back { selected = rail } + } + val density = androidx.compose.ui.platform.LocalDensity.current.density + remember(density) { Hops.density = density; true } + // Back (and B) from a ROM, an emulator, an added app or Flathub steps out one level instead + // of leaving the app: a ROM -> its emulator, the others -> Desktop. BackHandler( enabled = !processSettingsPageVisible && (s.pageKey == null || page == null) && - (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:")), + (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store"), ) { selected = if (selected.startsWith("rom:")) "emu:" + selected.removePrefix("rom:").substringBefore(':') else "desktop" } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } - // The Store turned off in Setup takes its page with it; so does Stores. - LaunchedEffect(s.storeEnabled) { if (!s.storeEnabled && selected == "store") selected = "steam" } - LaunchedEffect(s.gameStoresEnabled) { if (!s.gameStoresEnabled && selected == "stores") selected = "games" } // The last game uninstalled leaves the Games tab on its empty state. LaunchedEffect(s.steamGames.isEmpty()) { if (s.steamGames.isEmpty() && selected.startsWith("app:")) selected = "games" @@ -443,11 +465,14 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com remember { Motion.refresh(ctx); true } val railSelection = when { - s.pageKey == "performance" || s.pageKey == "protons" || s.pageKey == "controller-mapping" -> "setup" - s.pageKey?.startsWith("settings:steam") == true -> "steam" - s.pageKey?.startsWith("settings:") == true -> "desktop" + // A hop's page belongs to the rail item it flew to. + Hops.origin != null && s.pageKey == Hops.origin?.page -> Hops.origin!!.dest + // Performance opens from Play's sheet, and is Steam's. + s.pageKey == "performance" -> "steam" + s.pageKey == "controller-mapping" -> "setup" selected.startsWith("app:") -> "games" - selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") -> "desktop" + // Flathub is a page of Desktop's, as an emulator's is. + selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store" -> "desktop" else -> s.pageKey ?: selected } // Bumped each time a rail item is picked, so a controller moves on into the new page. @@ -458,6 +483,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com if (key == "components") a.onComponents(focusContent) else { if (s.pageKey != null) a.onPageBack() + // Another section: no way back to keep, and no sheet to put back. + Hops.origin = null + Hops.restore = null + Hops.sinking = null selected = key } } @@ -519,7 +548,22 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com val railFocus = Modifier .focusProperties { enter = { frontFocus.railFor(railSelection) } } .focusGroup() - Row(modifier = Modifier.fillMaxSize()) { + // Back from a hop onto a surface that is not a sheet (a game's Components card, Desktop's + // Flathub card): focus + // goes back to the control that asked, once the page has gone. + LaunchedEffect(Hops.restore) { + val r = Hops.restore ?: return@LaunchedEffect + if (r.surface == HOP_SURFACE_SHEET) return@LaunchedEffect + Hops.restore = null + if (r.rail != selected && !(r.rail == "games" && selected.startsWith("app:"))) selected = r.rail + kotlinx.coroutines.delay(Motion.ms(PAGE_EXIT_MS).toLong()) + if (inputModeManager.inputMode != InputMode.Keyboard) return@LaunchedEffect + frontFocus.last = r.control + focusWithinFrames({ frontFocus.last == r.control && anyFocused }) { frontFocus.items[r.control] ?: frontFocus.paneEntry() } + } + var screenAt by remember { mutableStateOf(Offset.Zero) } + CompositionLocalProvider(LocalShowSection provides { key -> selected = key }) { + Row(modifier = Modifier.fillMaxSize().onGloballyPositioned { screenAt = it.positionInRoot() }) { SideRail(s, railSelection, onRailSelect, onRailFocus, a, railFocus.fillMaxHeight()) Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line)) Column(modifier = Modifier.weight(1f).fillMaxHeight()) { @@ -535,6 +579,12 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } + } + // Dots on their way somewhere: a hop to another section and back, a swap flying home. + FlightsLayer(screenAt) + // A confirm stepping out of the control that asked for it. + StepsLayer(screenAt) + appToChooseDisplay?.let { app -> val secondaryDisplay = s.secondScreenDisplays.firstOrNull() ChooseAppDisplayDialog( @@ -597,8 +647,10 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com } } val hasBackTarget = (s.pageKey != null && page != null) || + // A section reached by a hop goes back to where it came from, not to the rail. + (s.pageKey == null && Hops.origin?.page == selected) || ((s.pageKey == null || page == null) && - (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:"))) + (selected.startsWith("emu:") || selected.startsWith("rom:") || selected.startsWith("user:") || selected == "store")) // At the top of a section, Back goes to the rail - the launcher itself is never backed out of. BackHandler(enabled = !processSettingsPageVisible && !hasBackTarget) { if (inputModeManager.inputMode != InputMode.Keyboard) inputModeManager.requestInputMode(InputMode.Keyboard) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index c0eb21e92..dfab93aa9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -71,6 +71,8 @@ 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.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import com.droiddeck.launcher.core.AppLanguage import com.droiddeck.launcher.core.DeviceSupport import com.droiddeck.launcher.core.PhantomProcessLimit @@ -166,21 +168,26 @@ internal fun SetupPanel( var showLimitDetails by rememberSaveable { mutableStateOf(false) } var editSgdbKey by remember { mutableStateOf(false) } if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false }) - // 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)) + // Four tabs by scope, a chip row with one gliding selection; 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_launcher), stringResource(R.string.setup_tab_diagnostics)) var tab by rememberSaveable { mutableStateOf(0) } - val tabFocus = remember { List(tabs.size) { FocusRequester() } } + val chipFocus = remember { List(4) { FocusRequester() } } var tabTurned by remember { mutableStateOf(false) } val pick: (Int) -> Unit = { i -> tab = i; tabTurned = true } val inputModeManager = LocalInputModeManager.current - // The control a controller was on went with the old tab: it lands on the new tab itself. + // The control a controller was on went with the old tab: it lands on the new tab's chip. LaunchedEffect(tab) { if (tabTurned && inputModeManager.inputMode == InputMode.Keyboard) { androidx.compose.runtime.withFrameNanos { } - runCatching { tabFocus[tab].requestFocus() } + runCatching { chipFocus[tab].requestFocus() } } } + val wide = !LocalNarrowPane.current + val runtimeOk = s.ready && !s.busy && !s.removalPending + val checks = listOf(gpuOk, runtimeOk, !limitBlocks, signedIn) + val passing = checks.count { it } + var showChecks by rememberSaveable { mutableStateOf(false) } Rise(0, Modifier.fillMaxSize()) { Column( modifier = Modifier.fillMaxSize().bumpers( @@ -189,15 +196,12 @@ internal fun SetupPanel( ), ) { PageHeader(stringResource(R.string.setup_title)) { - TabStrip(tabs, tab, pick, Modifier.weight(1f), tabFocus) + Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) } } Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { when (tab) { 0 -> { - SectionTitle(stringResource(R.string.setup_system_check), null) - // What Steam needs, one row each: green when done, one button when not. The - // process-limit controls only open under their row. - Column(modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { + val gpuCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( if (gpuOk) CheckState.OK else CheckState.WARN, when (gpu.support) { @@ -210,7 +214,10 @@ internal fun SetupPanel( com.droiddeck.launcher.gpu.GpuInfo.Support.UNTESTED -> stringResource(R.string.setup_gpu_untested_detail, gpu.displayName(ctx), gpu.supportText(ctx).replaceFirstChar { it.lowercase() }) else -> stringResource(R.string.setup_gpu_unsupported_detail, gpuName) }, + divider = divider, ) + } + val runtimeCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( when { s.busy -> CheckState.BUSY; !s.ready -> CheckState.WARN; else -> CheckState.OK }, stringResource(R.string.setup_runtime), @@ -221,7 +228,7 @@ internal fun SetupPanel( s.available != null && s.available != s.installed -> stringResource(R.string.setup_runtime_update, s.installed ?: stringResource(R.string.setup_installed)) else -> stringResource(R.string.setup_runtime_current, s.installed ?: stringResource(R.string.setup_installed)) }, - divider = !s.busy, + divider = divider && !s.busy, ) { SecondaryButton(runtime, enabled = !s.busy && !s.runtimeActionsBlocked && !s.sessionRunning, compact = true, onClick = a.onRuntime) } if (s.busy) { Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) { @@ -232,8 +239,10 @@ internal fun SetupPanel( if (s.percent >= 0) LinearProgressIndicator(progress = { s.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) } - Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + if (divider) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) } + } + val limitCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( if (limitBlocks) CheckState.WARN else CheckState.OK, stringResource(R.string.setup_limit), @@ -243,6 +252,7 @@ internal fun SetupPanel( PhantomProcessStatus.UNREADABLE -> stringResource(R.string.setup_limit_unknown) else -> PhantomProcessLimit.title(ctx, s.phantomProcessStatus) }, + divider = divider && !showLimitDetails, ) { if (limitBlocks) PrimaryButton(if (showLimitDetails) stringResource(R.string.common_hide) else stringResource(R.string.setup_fix_it), compact = true) { showLimitDetails = !showLimitDetails } else if (s.phantomProcessStatus != PhantomProcessStatus.NOT_APPLICABLE) { @@ -271,6 +281,9 @@ internal fun SetupPanel( processLimitMessage?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp)) } } } + if (divider && showLimitDetails) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + } + val accountCheck: @Composable (Boolean) -> Unit = { divider -> CheckRow( if (signedIn) CheckState.OK else CheckState.WARN, stringResource(R.string.setup_account), @@ -281,11 +294,42 @@ internal fun SetupPanel( else -> stringResource(R.string.setup_signed_in, it) } } ?: stringResource(R.string.setup_sign_in), - divider = false, + divider = divider, ) } - SectionTitle(stringResource(R.string.setup_tools), null) - ToolGrid(s, a) + val all = listOf(gpuCheck, runtimeCheck, limitCheck, accountCheck) + // Everything passes: one line says so, and Details opens the checks in place. + ReadinessCard( + passing, checks.size, + listOfNotNull( + gpu.displayName(ctx), + s.installed?.let { stringResource(R.string.setup_ready_runtime, it) }, + stringResource(R.string.setup_ready_limit_off).takeIf { !limitBlocks }, + s.offlineAccount?.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.setup_ready_signed_in, it) }, + ).joinToString(" · "), + open = showChecks, onDetails = { showChecks = !showChecks }, + ) { + val shownChecks = all.filterIndexed { i, _ -> checks[i] } + shownChecks.forEachIndexed { i, check -> check(i < shownChecks.lastIndex) } + } + // Each check that warns is its own card, its Fix it as it always was. + if (passing < checks.size) { + SectionTitle(stringResource(R.string.setup_needs_you), null) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + all.forEachIndexed { i, check -> + if (!checks[i]) Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, AttentionAmber.copy(alpha = 0.5f), Shape14)) { check(false) } + } + } + } + SettingsGroup(stringResource(R.string.setup_account)) { + accountCheck(true) + ToggleRow( + host, "offline", stringResource(R.string.setup_offline), + s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first), + s.offline, enabled = s.offlineAccount != null, + ) { a.onOffline() } + } + SettingsGroup(stringResource(R.string.setup_runtime)) { runtimeCheck(false) } } 1 -> { val controller = s.controller @@ -295,110 +339,79 @@ internal fun SetupPanel( if (s.controller == null || a.controller == null) Note(stringResource(R.string.setup_controller_unavailable)) } 2 -> { - SettingsGroup(stringResource(R.string.setup_session)) { - ChoiceRow( - host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)), - listOf( - false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), - true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU), - ), s.backActionsInverted, onPick = a.onBackActionsInverted, - ) - SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint)) { - Box { - ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } - FrameGenMenu(s, a, host) + val look: @Composable () -> Unit = { + SettingsGroup(stringResource(R.string.setup_group_look)) { + SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) { + Box { + ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" } + AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus -> + Themes.all.forEachIndexed { index, theme -> + MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) { + a.onTheme(theme.id) + host.open = null + } + } + } + } } + ChoiceRow( + host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), + com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> + percent to stringResource( + if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default + else R.string.ctrl_percent, percent, + ) + }, s.appScale, onPick = a.onAppScale, + ) + ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, + s.animationsEnabled, onChange = a.onAnimationsEnabled) + LanguageRow(host, s.language, a.onLanguage) } + } + val device: @Composable () -> Unit = { + SettingsGroup(stringResource(R.string.setup_group_device)) { + ToggleRow( + host, "home-screen", stringResource(R.string.setup_home), + if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off), + s.homeScreenEnabled, + ) { a.onHomeScreen(it) } + if (s.homeScreenEnabled) { + ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp) + } + ChoiceRow( + host, "orientation", stringResource(R.string.setup_orientation), null, + SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) }, + s.orientation, onPick = a.onOrientation, + ) + ToggleRow( + host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen), + if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off), + s.launcherFullscreen, + ) { a.onLauncherFullscreen(it) } + } + } + val art: @Composable () -> Unit = { + SettingsGroup(stringResource(R.string.setup_group_art)) { + ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) } + ActionRow( + stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null, + stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true }, + ) + } + } + // Two columns on a wide page: how it looks; this device and the library's art. + if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { look() } + Column(Modifier.weight(1f)) { device(); art() } + } else { look(); device(); art() } + } + 3 -> { + SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) { ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() } ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() } ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs, progress = com.droiddeck.launcher.session.SessionLogShare.progress) ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs) - ToggleRow( - host, "offline", stringResource(R.string.setup_offline), - s.offlineAccount?.let { if (it.isEmpty()) stringResource(R.string.setup_signed_in_unnamed) else stringResource(R.string.setup_signed_in, it) } ?: stringResource(R.string.setup_sign_in_first), - s.offline, enabled = s.offlineAccount != null, - ) { a.onOffline() } - } - } - 3 -> { - SettingsGroup(stringResource(R.string.setup_launcher)) { - LanguageRow(host, s.language, a.onLanguage) - ChoiceRow( - host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint), - com.droiddeck.launcher.core.AppUiPrefs.scales.map { percent -> - percent to stringResource( - if (percent == com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE) R.string.setup_app_scale_default - else R.string.ctrl_percent, percent, - ) - }, s.appScale, onPick = a.onAppScale, - ) - SettingsRow(stringResource(R.string.setup_theme), stringResource(R.string.setup_theme_hint)) { - Box { - ValueChip(stringResource(Themes.byId(s.theme).label), host.open == "theme") { host.open = if (host.open == "theme") null else "theme" } - AnchoredMenu(host.open == "theme", onDismiss = { if (host.open == "theme") host.open = null }, title = stringResource(R.string.setup_theme)) { firstItemFocus -> - Themes.all.forEachIndexed { index, theme -> - MenuItem(stringResource(theme.label), checked = s.theme == theme.id, focusRequester = if (index == 0) firstItemFocus else null) { - a.onTheme(theme.id) - host.open = null - } - } - } - } - } - ToggleRow( - host, "home-screen", stringResource(R.string.setup_home), - if (s.homeScreenEnabled) stringResource(R.string.setup_home_on) else stringResource(R.string.setup_home_off), - s.homeScreenEnabled, - ) { a.onHomeScreen(it) } - ChoiceRow( - host, "orientation", stringResource(R.string.setup_orientation), null, - SessionPrefs.orientationOptions.map { (value, label) -> value to stringResource(label) }, - s.orientation, onPick = a.onOrientation, - ) - ToggleRow( - host, "launcher-fullscreen", stringResource(R.string.setup_fullscreen), - if (s.launcherFullscreen) stringResource(R.string.setup_fullscreen_on) else stringResource(R.string.setup_fullscreen_off), - s.launcherFullscreen, - ) { a.onLauncherFullscreen(it) } - ToggleRow(host, "launcher-animations", stringResource(R.string.setup_animations), null, - s.animationsEnabled, onChange = a.onAnimationsEnabled) - if (s.homeScreenEnabled) { - ActionRow(stringResource(R.string.setup_default_home), s.defaultHomeLabel ?: stringResource(R.string.setup_choose_home), stringResource(R.string.setup_choose), a.onHomeApp) - } - } - SettingsGroup(stringResource(R.string.setup_linux_apps)) { - ToggleRow( - host, "store-enabled", stringResource(R.string.setup_store), - if (s.storeEnabled) stringResource(R.string.setup_store_on) - else stringResource(R.string.setup_store_off), - s.storeEnabled, - ) { a.onStoreEnabled(it) } - } - SettingsGroup(stringResource(R.string.content_games)) { - ToggleRow(host, "added-art", stringResource(R.string.mode_added_art), stringResource(R.string.mode_added_art_hint), s.addedGamesArt) { a.onAddedGamesArt(it) } - ActionRow( - stringResource(R.string.mode_sgdb_key), if (s.sgdbUserKey) stringResource(R.string.mode_sgdb_key_yours) else null, - stringResource(R.string.mode_sgdb_edit), onClick = { editSgdbKey = true }, - ) - } - SettingsGroup(stringResource(R.string.setup_stores)) { - ToggleRow( - host, "stores-enabled", stringResource(R.string.setup_stores_show), - null, s.gameStoresEnabled, - ) { a.onGameStoresEnabled(it) } - // The same rows as the Stores page's own cog, for whoever looks here first. - SettingsRow(stringResource(R.string.setup_stores_open_on), null) { - SegmentedTabs( - listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), - s.storesOpenTab, - ) { a.onStoresOpenTab(it) } - } - ToggleRow(host, "stores-show-mature", stringResource(R.string.stores_show_mature), null, s.storesShowMature) { a.onStoresShowMature(it) } - SettingsRow(stringResource(R.string.setup_stores_speed), null) { - SegmentedTabs(StoreDownloadTier.ALL.map { it.id to stringResource(it.label) }, s.gameStoresSpeedTier) { a.onGameStoresSpeedTier(it) } - } - StoresEngineRow() } } } @@ -407,50 +420,43 @@ internal fun SetupPanel( } } -/** The launcher's own tools as cards: four across, two by two on a narrow page. */ +/** + * The checks as one card while they all pass: a ring with how many pass, "Ready to play" and one + * line of what that means, and a Details chip that opens the checks themselves in place ([checks]). + */ @Composable -private fun ToolGrid(s: FrontEndState, a: FrontEndActions) { - val columns = if (LocalNarrowPane.current) 2 else 4 - val tools = listOf( - ToolSpec(Icons.Outlined.Folder, stringResource(R.string.setup_tool_files), stringResource(R.string.setup_tool_files_hint), a.onFiles), - ToolSpec(Icons.Outlined.Extension, stringResource(R.string.setup_tool_protons), stringResource(R.string.setup_tool_protons_hint), a.onProtons), - ToolSpec(Icons.Outlined.Speed, stringResource(R.string.setup_tool_performance), stringResource(R.string.setup_tool_performance_hint), a.onPerformance), - ToolSpec(Icons.Outlined.VideogameAsset, stringResource(R.string.setup_tool_roms), s.romsDir ?: stringResource(R.string.setup_tool_roms_hint), a.onRoms), - ) - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) { - for (row in tools.chunked(columns)) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - for (t in row) ToolCard(t, Modifier.weight(1f).fillMaxHeight()) - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } -} - -private class ToolSpec(val icon: ImageVector, val title: String, val detail: String, val onClick: () -> Unit) - -@Composable -private fun ToolCard(t: ToolSpec, modifier: Modifier) { +private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "toolScale") - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier.paneItem("tool:${t.title}") - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = t.onClick) - .controllerConfirm(onClick = t.onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), - ) { - Icon(t.icon, contentDescription = null, tint = if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) - Text(t.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(t.detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + val ready = passing == total + Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), + ) { + val tint = if (ready) pal.good else AttentionAmber + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp)) { + androidx.compose.material3.CircularProgressIndicator( + progress = { passing / total.toFloat().coerceAtLeast(1f) }, strokeWidth = 4.dp, + color = tint, trackColor = tint.copy(alpha = 0.18f), modifier = Modifier.size(52.dp), + ) + Text("$passing/$total", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + } + Column(Modifier.weight(1f)) { + Text( + stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready), + fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + ) + if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + if (passing > 0) SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true, onClick = onDetails) + } + AnimatedVisibility(open, enter = expandVertically(Motion.sp(1f)) + fadeIn(Motion.sp(1f)), exit = shrinkVertically(Motion.sp(1f)) + fadeOut(Motion.sp(1f))) { + Column(Modifier.fillMaxWidth()) { + Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line)) + checks() + } + } } } @@ -479,7 +485,15 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost var wincompOpen by remember(wincompKey) { mutableStateOf(null) } val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() } val cards = buildList<@Composable (Modifier) -> Unit> { - add { m -> SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m) { a.onComponents(true) } } + add { m -> + val back = stringResource(R.string.hop_from_games) + // Components is its own section: the card hops there, and B on it comes back here. + SettingCard(stringResource(R.string.setup_card_components), stringResource(R.string.setup_card_components_hint), "card:components", m.hopSource("card:components")) { + val at = Hops.sources["card:components"] + if (at == null) a.onComponents(true) + else Hops.go(HopOrigin("games", HOP_SURFACE_GAMES, null, "card:components", at, back, dest = "components", page = "components")) { a.onComponents(true) } + } + } add { m -> Box(m) { SettingCard(stringResource(R.string.frame_gen_title), s.frameGenLabel, "card:fg", Modifier.fillMaxSize()) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt index 9fe6ab6be..eec0f2087 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt @@ -57,6 +57,10 @@ 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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester /** One matched driver pair in the list (DriverPairs), and what this device makes of it. */ class PairRow( @@ -119,155 +123,38 @@ class GpuDriversActions( ) /** - * The GPU drivers tab: this GPU, and the drivers it draws with - a runtime (Linux) driver for the - * games and a display (Android) driver for the screen, set together as one matched pair. Auto keeps - * the pair recommended for the GPU installed and current; Manual picks a pair from the list; the - * two lists each on their own, and imports, are under Advanced. + * Everything about the chip - SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the + * drivers in use - gathered once, off the main thread, with a button that copies it for a bug + * report. Stepped out of the Components page's device chip; [first] is where a pad starts. */ @Composable -internal fun GpuDriversPanel(s: GpuDriversState, a: GpuDriversActions, onAdvanced: (String) -> Unit) { +internal fun DeviceDetails(first: FocusRequester) { val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var showAll by rememberSaveable { mutableStateOf(false) } - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { - DeviceCard(s) - if (!s.unsupported) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - ModeCard(Icons.Outlined.AutoAwesome, stringResource(R.string.common_auto), stringResource(R.string.gpu_auto_hint), s.auto, Modifier.weight(1f)) { a.onAuto(true) } - ModeCard(Icons.Outlined.Tune, stringResource(R.string.mode_suspend_manual), stringResource(R.string.gpu_manual_hint), !s.auto, Modifier.weight(1f)) { a.onAuto(false) } - } - if (s.auto) AutoCard(s) - else { - val shown = s.pairs.filter { it.suits || showAll } - if (shown.isEmpty()) Text( - if (s.checking) stringResource(R.string.gpu_checking) else stringResource(R.string.gpu_none_listed), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp), - ) - for (p in shown) PairLine(p, s.busy == p.key, s.percent, enabled = s.busy == null) { a.onPair(p.key) } - if (s.pairs.any { !it.suits }) FocusText( - if (showAll) stringResource(R.string.gpu_hide_other) else stringResource(R.string.gpu_show_other), pal.signal, - ) { showAll = !showAll } - } - } - SettingsGroup(stringResource(R.string.gpu_advanced)) { - SettingsRow(stringResource(R.string.comp_runtime_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_display, it) } ?: stringResource(R.string.gpu_runtime_hint)) { - DriverDropdown(s.linuxRows, s.linuxSelected, stringResource(R.string.gpu_runtime_default), a.onSelectLinux) { onAdvanced("rt") } - } - SettingsRow(stringResource(R.string.comp_display_driver), s.activeBundle?.let { stringResource(R.string.gpu_set_with_runtime, it) } ?: stringResource(R.string.gpu_display_hint)) { - DriverDropdown(s.androidRows, s.androidSelected, stringResource(R.string.gpu_auto_by_gpu), a.onSelectAndroid) { onAdvanced("panel") } - } - } - } -} - -/** - * One driver list as a drop-down: the installed drivers to pick from, and the full page for - * downloading and deleting them. An Android + Linux bundle's row is tagged, and picking it sets both. - */ -@Composable -private fun DriverDropdown(rows: List, selected: String, fallback: String, onSelect: (String) -> Unit, onManage: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - var open by remember { mutableStateOf(false) } - Box { - SecondaryButton((rows.firstOrNull { it.id == selected }?.name ?: fallback) + " ▾", compact = true) { open = true } - DropdownMenu(open, onDismissRequest = { open = false }, modifier = Modifier.heightIn(max = 360.dp)) { - for (row in rows) DropdownMenuItem( - leadingIcon = { Radio(row.id == selected) }, - text = { - Column { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(row.name, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (row.id == selected) pal.signal else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (row.tag == DriverRow.BUNDLE) Text(driverTagLabel(row.tag), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = colors.onSurfaceVariant) - } - if (row.detail.isNotEmpty()) Text(row.detail, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - }, - onClick = { open = false; if (row.id != selected) onSelect(row.id) }, - ) - HorizontalDivider(color = pal.line) - DropdownMenuItem(text = { Text(stringResource(R.string.gpu_manage), fontSize = 14.sp, color = pal.signal) }, onClick = { open = false; onManage() }) - } - } -} - -/** - * This GPU and how far it is supported. A tap (or A) opens the card onto everything about the chip - - * SoC, GPU, CPU, memory, system, the phone's own Vulkan driver, the drivers in use - gathered once, - * off the main thread, with a button that copies it for a bug report. - */ -@Composable -private fun DeviceCard(s: GpuDriversState) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current val context = LocalContext.current val clipboard = LocalClipboardManager.current - val tint = when { - s.unsupported -> pal.error - s.supported -> pal.good - else -> AttentionAmber - } - var open by rememberSaveable { mutableStateOf(false) } var details by remember { mutableStateOf?>(null) } var copied by remember { mutableStateOf(false) } - LaunchedEffect(open) { - if (open && details == null) details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } - } - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val toggle = { open = !open; copied = false } + LaunchedEffect(Unit) { details = withContext(Dispatchers.IO) { DeviceInfo.collect(context) } } Column( - Modifier.fillMaxWidth().clip(Shape14).background(colors.surface) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) - .glideBorder(hot, Shape14, pal.signal, pal.line), + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).verticalScroll(rememberScrollState()), ) { - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth().paneItem("gpu-device") - .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.Button, onClick = toggle) - .controllerConfirm(onClick = toggle) - .padding(14.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(Shape12).background(tint.copy(alpha = 0.14f))) { - Icon(Icons.Outlined.Memory, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) - } - Column(Modifier.weight(1f)) { - Text(s.gpuName, fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) - Text(listOf(s.soc, s.gpuFamily).filter { it.isNotEmpty() }.joinToString(" · "), fontSize = 13.sp, color = colors.onSurfaceVariant) - } - // The verdict shares the width with the GPU's name and wraps, rather than taking all it - // needs: a long one (in some languages) would squeeze the name to a letter a line. - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f, fill = false)) { - Box(Modifier.size(7.dp).clip(CircleShape).background(tint)) - Text(s.supportText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = tint, maxLines = 3, overflow = TextOverflow.Ellipsis) - } - Icon( - if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, - contentDescription = stringResource(if (open) R.string.device_info_hide else R.string.device_info_show), - tint = colors.onSurfaceVariant, modifier = Modifier.size(24.dp), - ) - } - AnimatedVisibility(open) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp)) { - HorizontalDivider(color = pal.line) - val sections = details - if (sections == null) { - Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) - LinearProgressIndicator(Modifier.fillMaxWidth()) - } else { - for (section in sections) DeviceInfoSection(section) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f), - ) - SecondaryButton(stringResource(R.string.device_info_copy), compact = true) { - clipboard.setText(AnnotatedString(DeviceInfo.text(sections))) - copied = true - } - } + val sections = details + if (sections == null) { + Text(stringResource(R.string.device_info_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) + LinearProgressIndicator(Modifier.fillMaxWidth()) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(if (copied) R.string.device_info_copied else R.string.device_info_copy_hint), + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f), + ) + SecondaryButton(stringResource(R.string.device_info_copy), compact = true, modifier = Modifier.focusRequester(first)) { + clipboard.setText(AnnotatedString(DeviceInfo.text(sections))) + copied = true } } + for (section in sections) DeviceInfoSection(section) } } } @@ -288,115 +175,9 @@ private fun DeviceInfoSection(section: DeviceInfo.Section) { Text(section.title.uppercase(), fontSize = 11.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) for ((label, value) in section.rows) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(190.dp)) + Text(label, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(150.dp)) Text(value, fontSize = 13.sp, fontWeight = FontWeight.Medium, color = colors.onBackground, modifier = Modifier.weight(1f)) } } } } - -/** What Auto has set and what it is doing. */ -@Composable -private fun AutoCard(s: GpuDriversState) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val active = s.activePair - val recommended = s.pairs.firstOrNull { it.recommended } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(14.dp), - ) { - Text(stringResource(R.string.gpu_in_use_caps), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant) - Text( - active?.let { "${it.name} ${it.version}" } ?: s.activeBundle ?: stringResource(R.string.gpu_builtin), - fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, - ) - Text( - if (active != null || s.activeBundle != null) stringResource(R.string.gpu_matched_pair) - else recommended?.let { stringResource(R.string.gpu_recommended_for, it.name) } ?: stringResource(R.string.gpu_refresh_hint), - fontSize = 13.sp, color = colors.onSurfaceVariant, - ) - if (s.busy != null) { - LinearProgressIndicator( - progress = { (s.percent.coerceAtLeast(0)) / 100f }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape), - ) - } - if (s.autoStatus.isNotEmpty()) Text(s.autoStatus, fontSize = 13.sp, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp)) - } -} - -@Composable -private fun ModeCard(icon: ImageVector, title: String, hint: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - // Selection is the radio; the ring is only ever focus. - val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("gpu-mode:$title").heightIn(min = 56.dp) - .clip(Shape14).background(colors.surface).background(fill) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = null, role = Role.RadioButton, onClick = onClick) - .controllerConfirm(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 10.dp), - ) { - Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp)) - Column(Modifier.weight(1f)) { - Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Radio(selected) - } -} - -@Composable -private fun PairLine(p: PairRow, busy: Boolean, percent: Int, enabled: Boolean, onClick: () -> Unit) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val usable = enabled && p.complete && !p.active - val fill = if (hot) pal.signal.copy(alpha = 0.14f) else Color.Transparent - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().paneItem("pair:${p.key}").heightIn(min = 56.dp) - .clip(Shape12).background(colors.surface).background(fill) - .glideBorder(hot, Shape12, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = null, enabled = usable, role = Role.RadioButton, onClick = onClick) - .controllerConfirm(enabled = usable, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) { - Radio(p.active) - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text(p.name, fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - if (p.recommended) Text( - stringResource(R.string.gpu_recommended_caps), fontSize = 10.5.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp, color = pal.good, - modifier = Modifier.clip(RoundedCornerShape(5.dp)).background(pal.good.copy(alpha = 0.12f)).padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - Text("${p.version} · ${p.detail}", fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - when { - busy -> Column(horizontalAlignment = Alignment.End, modifier = Modifier.width(96.dp)) { - Text(if (percent >= 0) "$percent%" else "…", fontSize = 12.sp, color = colors.onSurfaceVariant) - LinearProgressIndicator(progress = { percent.coerceAtLeast(0) / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) - } - p.active -> Text(stringResource(R.string.comp_tag_in_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal) - !p.complete -> Text(stringResource(R.string.gpu_unavailable), fontSize = 13.sp, color = colors.onSurfaceVariant) - p.installed -> Text(stringResource(R.string.common_use), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - else -> Text(stringResource(R.string.common_download), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - } - } -} - -@Composable -private fun Radio(selected: Boolean) { - val pal = LocalPalette.current - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) { - if (selected) Box(Modifier.size(10.dp).clip(CircleShape).background(pal.signal)) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt new file mode 100644 index 000000000..a5ee4c8f2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Hop.kt @@ -0,0 +1,174 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R + +// A link from a control in one section to a page in another (the Play sheet's Graphics drivers, +// a game's Components card): the control squashes into a dot that flies to the destination's rail +// item, the rail's selection glides there as it passes the top of its arc, and the page floods +// out of the rail item once it lands. B on that page flies the dot back and puts the source +// surface back as it was, focus on the control that asked. + +/** + * Where a hop came from: the rail item that was showing ([rail]), the surface on it (a sheet, the + * Games page) and its tab, the control's key and its root bounds, and the words for the way back. + * [page] is the page key the hop opened, so leaving it any other way forgets the way back. + */ +internal class HopOrigin( + val rail: String, + val surface: String, + val tab: String?, + val control: String, + val bounds: Rect, + val back: String, + val dest: String, + val page: String, +) + +/** The surfaces a hop can start from and come back to. */ +internal const val HOP_SURFACE_GAMES = "games" +internal const val HOP_SURFACE_DESKTOP = "desktop" +internal const val HOP_SURFACE_SHEET = "sheet" + +internal object Hops { + /** The hop the page on screen was reached by, while B can still undo it. */ + var origin by mutableStateOf(null) + + /** The pane content a hop is leaving (a rail key): it sinks back as the dot leaves it. */ + var sinking by mutableStateOf(null) + + /** Set while a hop's dot is flying: the rail item its selection has already glided to. */ + var railPreview by mutableStateOf(null) + + /** + * Set as a page leaves through a hop (either way), so the pane sinks it rather than draining + * it; the pane clears it once the change has started. + */ + var linking by mutableStateOf(false) + + /** A surface to put back as it was (a sheet re-lifted on its tab), once, and the control to focus. */ + var restore by mutableStateOf(null) + + /** Per rail item, bumped when a dot lands on it: the item pops. */ + val railLanded = mutableStateMapOf() + + /** Bumped when a hop's page has flooded in: its target (a layer card) sends out one ring. */ + var arrived by mutableIntStateOf(0) + + /** Where each rail item sits, root px. */ + val railBounds = HashMap() + + /** Where each control that can start a hop sits, root px. */ + val sources = HashMap() + + /** + * Hops from the control at [origin].bounds to [origin].dest: [go] opens the page there once the dot lands. + * With animations off there is no dot and no flood, only the page. + */ + fun go(origin: HopOrigin, go: () -> Unit) { + val railAt = railBounds[origin.dest] + val finish: () -> Unit = { + railPreview = null + this.origin = origin + if (railAt != null && Motion.scale != 0f) PageOrigin.mark(Origin(railAt, 14f * density, owner = origin.dest)) + linking = true + go() + railLanded[origin.dest] = (railLanded[origin.dest] ?: 0) + 1 + arrived++ + } + if (railAt == null || Motion.scale == 0f) { finish(); return } + // The surface the control is on sinks as its dot leaves, and stays down as it goes. + sinking = origin.rail + Flights.fly(Flight( + origin.bounds, to = { railAt.center }, + onHalf = { railPreview = origin.dest }, + onLand = finish, + )) + } + + /** + * B on a page reached by a hop: the page sinks, the source surface comes back as it was, and + * the dot flies home from the rail item to the control. False when there is no hop to undo. + */ + fun back(close: () -> Unit): Boolean { + val o = origin ?: return false + origin = null + sinking = null + val railAt = railBounds[o.dest] + railPreview = if (railAt != null && Motion.scale != 0f) o.dest else null + restore = o + linking = true + close() + if (railAt == null || Motion.scale == 0f) { railPreview = null; return true } + Flights.fly(Flight( + railAt, to = { (sources[o.control] ?: o.bounds).center }, + onHalf = { railPreview = null }, + )) + return true + } + + /** The page the hop opened has gone some other way: there is no way back to keep. */ + fun forget(pageKey: String?) { + val o = origin ?: return + if (pageKey != o.page) { origin = null; sinking = null } + } + + /** Screen density, for the rail item's corner; set by the front end. */ + var density = 1f +} + +/** Shows a rail section directly (no page closing, nothing forgotten): what a hop's way back calls. */ +internal val LocalShowSection = androidx.compose.runtime.staticCompositionLocalOf<(String) -> Unit> { {} } + +/** Remembers where this control sits, so a hop from it can fly from it and back to it. */ +internal fun Modifier.hopSource(key: String): Modifier = onGloballyPositioned { Hops.sources[key] = it.boundsInRoot() } + +/** + * "B · back to " at the right of a page's header while it was reached by a hop; a tap is B. + * Nothing when it was not. + */ +@Composable +internal fun HopBackChip(onBack: () -> Unit) { + val o = Hops.origin ?: return + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Text( + stringResource(R.string.hop_back, o.back), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape) + .background(pal.signal.copy(alpha = if (hot) 0.16f else 0.08f)) + .glideBorder(hot, shape, pal.signal, pal.line2) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onBack) + .controllerConfirm(onClick = onBack) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt new file mode 100644 index 000000000..60667ee92 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -0,0 +1,805 @@ +package com.droiddeck.launcher.ui + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.animation.core.animateFloat +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp +import com.droiddeck.launcher.R +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +// Play's settings, lifted out from under Play instead of opened on a page of their own. At rest a +// lip tucks out under Play and says what it will do. Down (or a pull up, or a tap on the lip) +// stretches the lip, the wall sinks back, and a sheet rises from the pane's foot with Play riding +// its top edge as the handle - StepOut's move, with the pane for a box. B, Up from the chips or a +// tap above folds it away; A on Play launches straight from it, the sheet folding into the pill +// before the launch flood. Open desktop does the same with the desktop's settings. + +/** The sheets' tabs (their chips), by id. */ +internal object SheetTab { + const val DISPLAY = "display" + const val CONTROLS = "controls" + const val AUDIO = "audio" + const val SESSION = "session" + const val LIBRARY = "library" + const val STEAM = "steam" + const val FILES = "files" +} + +/** + * Where the open sheet's top edge sits in a pane [paneH] tall (px), for a button [buttonH] tall + * riding it: 82dp down a 360dp pane, the same share of any other, and never so high that the + * button's top comes closer than [minAbove] to the pane's. [ride] is how far below the edge the + * button's centre sits. + */ +internal fun sheetTop(paneH: Float, buttonH: Float, minAbove: Float, ride: Float): Float = + maxOf(paneH * 82f / 360f, minAbove + buttonH / 2f - ride) + +/** A finger's hold on the sheet: where it started (0 rest, 1 open), where it is, whether it has it. */ +private class SheetDrag { + var from = 0f + var progress = 0f + var engaged = false +} + +/** One piece of the sheet's rows fades and rises in on its own beat (a settings group asks for its index). */ +internal class SheetBeat(private val pieces: List>) { + private var next = 1 + fun take(): Int = next++ + fun value(i: Int): Float = pieces[i.coerceIn(0, pieces.lastIndex)].value +} + +/** The sheet whose rows are being laid out, so each settings group can take its beat. */ +internal val LocalSheetBeat = staticCompositionLocalOf { null } + +/** A settings group's piece of the sheet's entrance; nothing outside a sheet. */ +@Composable +internal fun Modifier.sheetPiece(): Modifier { + val beat = LocalSheetBeat.current ?: return this + val i = remember { beat.take() } + return graphicsLayer { + val v = beat.value(i) + alpha = v + translationY = (1f - v) * 6.dp.toPx() + } +} + +private val SheetInset = 12.dp +private val SheetCorner = 18.dp +private val ChipsTop = 14.dp +private val RowsTop = 54.dp +private const val PIECES = 12 + +/** + * A hero with one big button and the sheet that lifts out from under it. [key] names the sheet + * (a hop comes back to it, the lip remembers what it said); [tabs] are its chips (id to label) and + * [rows] what each holds. [background] sinks and [words] fade as it opens. [button] draws the + * button itself with the click it should make; [onPrimary] is what that click does once the + * sheet is out of the way. [onOpen] reads the settings the rows show. + */ +@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) +@Composable +internal fun LiftHero( + key: String, + tabs: List>, + onOpen: () -> Unit, + onPrimary: () -> Unit, + background: @Composable () -> Unit, + /** + * Drawn over [background] at the pane's own size: the wall's fades, which must not sink with + * it. Given how far the sheet is up (0 to 1), for anything there that should give way to it. + */ + scrim: @Composable (lifted: () -> Float) -> Unit = {}, + words: @Composable () -> Unit, + /** + * Buttons beside the big one (Desktop's Flathub and +). They stay where they are and fade as + * the sheet lifts: only the big button rides it. + */ + beside: @Composable () -> Unit = {}, + button: @Composable (onClick: () -> Unit) -> Unit, + rows: @Composable (tab: String) -> Unit, + modifier: Modifier = Modifier, + /** + * Down on the button lifts the sheet (Play). False: Down goes to the lip, which A lifts, and Down + * from the lip goes on past it to what is under the hero (Desktop's shelves). + */ + downOpens: Boolean = true, + /** Told when the sheet starts to lift (true) and once it has gone (false). */ + onOpenChange: (Boolean) -> Unit = {}, + start: Dp = 44.dp, + bottom: Dp = 44.dp, +) { + val density = LocalDensity.current + val inputMode = LocalInputModeManager.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val ff = LocalFrontFocus.current + val narrow = LocalNarrowPane.current + // Held as states too, so the sheet's focus rule reads what they are now, not at the last composition. + val openState = remember { mutableStateOf(false) } + var open by openState + var shown by remember { mutableStateOf(false) } + val launchingState = remember { mutableStateOf(false) } + var launching by launchingState + var tab by remember { mutableStateOf(0) } + var travel by remember { mutableStateOf(1) } + // The control to focus once a sheet put back by a hop's B has lifted, instead of the first chip. + var focusOnLift by remember { mutableStateOf(null) } + // Set as the sheet closes and Play lands: the lip may come back before the sheet has quite gone. + var lipBack by remember { mutableStateOf(false) } + // Set as a finger's drag lets go: the sheet is already where it is going. + var dragSettled by remember { mutableStateOf(false) } + val scope = androidx.compose.runtime.rememberCoroutineScope() + val onOpenNow = rememberUpdatedState(onOpen) + val pull = remember { Animatable(0f) } + val sink = remember { Animatable(0f) } + val lift = remember { Animatable(0f) } + val ride = remember { Animatable(0f) } + val hot = remember { Animatable(1f) } + val lipShow = remember { Animatable(0f) } + val lipGone = remember { Animatable(0f) } + val fold = remember { Animatable(0f) } + val foldTop = remember { Animatable(0f) } + val pieces = remember { List(PIECES) { Animatable(0f) } } + val chipFocus = remember(tabs.size) { List(tabs.size) { FocusRequester() } } + val playSrc = remember { MutableInteractionSource() } + val playHovered by playSrc.collectIsHoveredAsState() + var playFocused by remember { mutableStateOf(false) } + var heroAt by remember { mutableStateOf(Offset.Zero) } + var rest by remember { mutableStateOf(null) } + fun focusPlay() { if (ff != null && inputMode.inputMode == InputMode.Keyboard) runCatching { ff.primary.requestFocus() } } + fun close() { + if (!open) return + // Focus leaves the rows before they go; the sheet lets it go once it is closing. + open = false + focusPlay() + } + // Back from a hop the sheet started (or a page it opened): put it back as it was. + LaunchedEffect(Unit) { + val r = Hops.restore ?: return@LaunchedEffect + if (r.surface != HOP_SURFACE_SHEET || r.rail != key) return@LaunchedEffect + Hops.restore = null + tab = tabs.indexOfFirst { it.first == r.tab }.coerceAtLeast(0) + focusOnLift = r.control + open = true + } + // Launched: the session covers the screen, and the sheet is put away under it. + val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val obs = androidx.lifecycle.LifecycleEventObserver { _, e -> + if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (open || shown)) { + open = false; shown = false; launching = false + } + } + lifecycle.addObserver(obs) + onDispose { lifecycle.removeObserver(obs) } + } + LaunchedEffect(open, launching) { + if (dragSettled) { + // A finger brought it here and has already animated it: only what opening needs. + dragSettled = false + if (open) { + if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[tab].requestFocus() } } + } else focusPlay() + return@LaunchedEffect + } + if (open && !launching) { + lipBack = false + shown = true + onOpen() + fold.snapTo(0f); foldTop.snapTo(0f) + coroutineScope { + // Pull: the lip stretches down as the wall sinks back and dims. + launch { pull.animateTo(24f, Motion.sp(0.7f, 380f)) } + launch { sink.animateTo(1f, Motion.tw(260)) } + // Lift: the sheet rises with Play riding its top edge; the lip lets go. + launch { + delay(Motion.ms(120).toLong()) + launch { lipGone.animateTo(1f, Motion.tw(140)) } + launch { pull.animateTo(0f, Motion.sp(1f, 500f)) } + launch { lift.animateTo(1f, Motion.sp(0.7f, 380f)) } + launch { ride.animateTo(1f, Motion.sp(0.7f, 380f)) } + launch { hot.snapTo(1f); delay(Motion.ms(100).toLong()); hot.animateTo(0f, Motion.tw(200)) } + } + // Each piece on its beat from the Lift, as the reel has it: 260ms + 55ms a piece after it starts. + pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } } + if (inputMode.inputMode == InputMode.Keyboard) launch { + val control = focusOnLift + focusOnLift = null + delay(Motion.ms(if (control != null) 420 else 300).toLong()) + withFrameNanos { } + val target = control?.let { ff?.items?.get(it) } ?: chipFocus[tab] + runCatching { target.requestFocus() } + } + } + } else if (launching) { + // Play from the sheet: the rows go, the sheet folds into the pill, then the launch. + coroutineScope { + pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); fold.animateTo(1f, Motion.sp(1f, 420f)) } + launch { delay(Motion.ms(160).toLong()); foldTop.animateTo(1f, Motion.sp(1f, 380f)) } + } + shown = false + onPrimary() + // The session covers the screen and the sheet is put away under it (ON_STOP). If + // nothing started, put it away anyway. + delay(3_000) + launching = false + } else if (shown) { + coroutineScope { + pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); lift.animateTo(0f, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(100).toLong()); ride.animateTo(0f, Motion.sp(0.55f, 300f)) } + launch { sink.animateTo(0f, Motion.tw(300)) } + // The lip tucks back under Play as it lands, not once every spring has settled. + launch { delay(Motion.ms(420).toLong()); lipGone.snapTo(0f); lipBack = true } + } + shown = false + lipBack = false + lipGone.snapTo(0f) + hot.snapTo(1f) + } else { + // Snapped shut under a session: everything back at rest, no animation. + listOf(pull, sink, lift, ride, lipGone, fold, foldTop).forEach { it.snapTo(0f) } + pieces.forEach { it.snapTo(0f) } + hot.snapTo(1f) + } + } + BackHandler(enabled = open && !launching) { close() } + val openNow = open || shown + LaunchedEffect(openNow) { onOpenChange(openNow) } + + // The lip: there while Play has a pad or a pointer on it, or always with touch alone. + val touchOnly = inputMode.inputMode == InputMode.Touch + val lipFocus = remember { FocusRequester() } + val settingsLabel = stringResource(R.string.drawer_page_settings) + var lipFocused by remember { mutableStateOf(false) } + val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused) + LaunchedEffect(lipWanted) { + if (lipWanted) { + // Back from the sheet it comes straight away; otherwise a beat after Play has focus. + if (!lipBack) delay(Motion.ms(160).toLong()) + lipShow.animateTo(1f, Motion.tw(220)) + } else { + delay(Motion.ms(140).toLong()) + lipShow.animateTo(0f, Motion.tw(220)) + } + } + + // How far the sheet travels, for a finger's drag to follow: set once the hero is measured. + val travelPx = remember { floatArrayOf(1f) } + val drag = remember { SheetDrag() } + /** The sheet [p] of the way up (0 at rest, 1 open) under a finger. */ + fun follow(p: Float) { + drag.progress = p + scope.launch { + lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f) + lipGone.snapTo(1f) + pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) } + } + } + /** A finger takes the sheet where it is: up from rest, or down from open. */ + fun beginDrag() { + drag.engaged = true + drag.from = if (open) 1f else 0f + drag.progress = drag.from + if (!open) { + shown = true + scope.launch { fold.snapTo(0f); foldTop.snapTo(0f) } + onOpenNow.value() + } + } + /** + * The finger lets go with [v] px/s (up negative): a flick goes the way it was flicked; a slow + * drag stays once it is a third of the way from where it started. + */ + fun settleDrag(v: Float) { + drag.engaged = false + val flick = with(density) { 600.dp.toPx() } + val opens = when { + v < -flick -> true + v > flick -> false + drag.from > 0.5f -> drag.progress > 0.7f + else -> drag.progress > 0.3f + } + dragSettled = true + scope.launch { + val to = if (opens) 1f else 0f + coroutineScope { + launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) } + launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) } + launch { sink.animateTo(to, Motion.tw(220)) } + pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } } + } + if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false } + } + if (open != opens) open = opens else dragSettled = false + } + // Pulled down past the top of the rows, the sheet itself comes down with the finger; up again + // before letting go, it goes back up before the rows scroll. + val rowsPull = remember { + object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection { + override fun onPreScroll(available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset { + if (!drag.engaged || available.y >= 0f) return Offset.Zero + val before = drag.progress + follow((before - available.y / travelPx[0]).coerceAtMost(1f)) + return Offset(0f, (drag.progress - before) * -travelPx[0]) + } + override fun onPostScroll(consumed: Offset, available: Offset, source: androidx.compose.ui.input.nestedscroll.NestedScrollSource): Offset { + if (available.y <= 0f || source != androidx.compose.ui.input.nestedscroll.NestedScrollSource.Drag || !openState.value || launchingState.value) return Offset.Zero + if (!drag.engaged) beginDrag() + follow((drag.progress - available.y / travelPx[0]).coerceIn(0f, 1f)) + return available + } + override suspend fun onPreFling(available: androidx.compose.ui.unit.Velocity): androidx.compose.ui.unit.Velocity { + if (!drag.engaged) return androidx.compose.ui.unit.Velocity.Zero + settleDrag(available.y) + return available + } + } + } + BoxWithConstraints( + modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft } + // A swipe anywhere on the hero moves the sheet under the finger - up from rest, down + // when open - and lets go open or shut by how far it went and how fast. A swipe that + // starts the wrong way (down at rest) is left alone, for the page under it. + .pointerInput(Unit) { + val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker() + var decided = false + detectVerticalDragGestures( + onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false }, + onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) }, + onDragCancel = { if (drag.engaged) settleDrag(0f) }, + ) { change, amount -> + if (launching) return@detectVerticalDragGestures + if (!decided) { + decided = true + // Up at rest opens; down when open closes. Anything else is not for the sheet. + if (if (open) amount > 0f else amount < 0f) beginDrag() + } + if (!drag.engaged) return@detectVerticalDragGestures + change.consume() + tracker.addPosition(change.uptimeMillis, change.position) + follow((drag.progress - amount / travelPx[0]).coerceIn(0f, 1.04f)) + } + }, + ) { + val w = constraints.maxWidth.toFloat() + val h = constraints.maxHeight.toFloat() + val inset = with(density) { SheetInset.toPx() } + val restRect = rest?.translate(-heroAt) + val buttonH = restRect?.height ?: with(density) { 56.dp.toPx() } + val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() }) + // Play's raised place: its centre 3dp under the sheet's edge, same left inset. + val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f + travelPx[0] = (h - openTop).coerceAtLeast(1f) + + // The wall behind sinks back about the pane's centre. + val foot = h + Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() } + // The fades stay put: scaled with the wall they stopped short of the pane's edges and left + // a bright band of unfaded wall down the side and along the top. + scrim { sink.value } + // The dim; a tap on it (above the sheet) folds the sheet away. + if (shown || open) Box( + Modifier.fillMaxSize() + .graphicsLayer { alpha = sink.value }.background(Color.Black.copy(alpha = 0.42f)) + .focusProperties { canFocus = false } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open && !launching) { close() }, + ) + + // The sheet: inset at the sides, rounded at the top, no bottom edge - the pane's foot is its foot. + if (shown) { + val top = lerp(foot, openTop, lift.value) + val sheetLeft = inset + val sheetRight = w - inset + val path = remember { Path() } + val rimPath = remember { Path() } + val corner = with(density) { SheetCorner.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val pill = restRect?.translate(0f, raise) + Canvas(Modifier.fillMaxSize()) { + val f = fold.value + val ft = foldTop.value + val l = if (pill != null) lerp(sheetLeft, pill.left, f) else sheetLeft + val r = if (pill != null) lerp(sheetRight, pill.right, f) else sheetRight + val t = if (pill != null) lerp(top, pill.top, ft) else top + val b = if (pill != null) lerp(foot + corner, pill.bottom, ft) else foot + corner + val c = if (pill != null) lerp(corner, with(density) { 12.dp.toPx() }, ft) else corner + val bc = if (ft > 0f) c else 0f + path.reset() + path.addRoundRect(RoundRect(l, t, r, b, CornerRadius(c), CornerRadius(c), CornerRadius(bc), CornerRadius(bc))) + val fill = lerp(colors.surface, pal.signal, 0.22f * hot.value) + drawPath(path, fill) + val rimColor = pal.signal.copy(alpha = lerp(0.55f, 0.9f, hot.value)) + if (ft > 0f) drawPath(path, rimColor, style = Stroke(rim)) + else { + // Sides and top only: the sheet runs on under the pane's foot. + rimPath.reset() + rimPath.moveTo(l, b) + rimPath.lineTo(l, t + c) + rimPath.arcTo(Rect(l, t, l + 2 * c, t + 2 * c), 180f, 90f, false) + rimPath.lineTo(r - c, t) + rimPath.arcTo(Rect(r - 2 * c, t, r, t + 2 * c), -90f, 90f, false) + rimPath.lineTo(r, b) + drawPath(rimPath, rimColor, style = Stroke(rim)) + } + } + // What the sheet holds: the chips to the right of Play, the rows under them. + val chipsX = with(density) { + if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft + } + val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() } + // The rows start under the chips, which are 44dp to touch: from 54dp they ran under the + // chips' feet and, scrolled, covered them. + val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) } + val sheetW = sheetRight - sheetLeft + Box( + Modifier + .offset { IntOffset(sheetLeft.roundToInt(), top.roundToInt()) } + .size(with(density) { sheetW.toDp() }, with(density) { (foot - openTop).coerceAtLeast(0f).toDp() }) + // Touches on the sheet stay on it; none reach the dim behind. + .pointerInput(Unit) { detectTapGestures { } } + .controllerBack { close() } + .bumpers( + // LB / RB: focus goes to the new tab's chip, so a pad on a row (which goes with + // its tab) is never left with nothing. + onPrevious = { if (tab > 0) { travel = -1; tab--; runCatching { chipFocus[tab].requestFocus() } } }, + onNext = { if (tab < tabs.lastIndex) { travel = 1; tab++; runCatching { chipFocus[tab].requestFocus() } } }, + ) + // A pad stays in the sheet until it folds; Left from it reaches Play, the handle. + .focusProperties { exit = { dir -> if (!openState.value || launchingState.value) FocusRequester.Default else if (dir == FocusDirection.Left && ff != null) ff.primary else FocusRequester.Cancel } } + .focusGroup(), + ) { + Box( + Modifier + .offset { IntOffset(chipsX.roundToInt(), chipsY.roundToInt()) } + // Over the rows, whatever they scroll under them. + .zIndex(1f) + .width(with(density) { (sheetW - chipsX - 16.dp.toPx()).coerceAtLeast(0f).toDp() }) + .graphicsLayer { val v = pieces[0].value; alpha = v; translationY = (1f - v) * 6.dp.toPx() } + // Up from the chips folds the sheet (a finger drags it down, see the hero's drag). + .onPreviewKeyEvent { e -> + val k = e.nativeKeyEvent + if (k.keyCode == KeyEvent.KEYCODE_DPAD_UP) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false + }, + ) { + SheetChips(tabs.map { it.second }, tab, chipFocus) { i -> travel = if (i > tab) 1 else -1; tab = i } + } + Box( + Modifier + .offset { IntOffset(0, rowsY.roundToInt()) } + .nestedScroll(rowsPull) + .fillMaxWidth() + .height(with(density) { (foot - openTop - rowsY).coerceAtLeast(0f).toDp() }), + ) { + AnimatedContent( + targetState = tab, + transitionSpec = { + val d = travel + val shift = with(density) { 24.dp.roundToPx() } + (slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240))) + .togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240))) + }, + label = "sheetTab", + ) { t -> + val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit + val leavingNow = rememberUpdatedState(leaving) + CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(pieces) }) { + Column( + Modifier.fillMaxSize() + // Focus never lands in rows on their way out. Same group always, only + // its rule changes (canFocus here made the scroll's own group a stop). + .focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp), + ) { rows(tabs[t].first) } + } + } + } + } + } + + // The words and the button, where they always were; the button rides the sheet's edge. + Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) { + Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() } + Spacer(Modifier.height(18.dp)) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier + .onGloballyPositioned { rest = it.boundsInRoot() } + .graphicsLayer { translationY = raise * ride.value } + .onFocusChanged { playFocused = it.hasFocus } + .hoverable(playSrc) + .onPreviewKeyEvent { e -> + val k = e.nativeKeyEvent + if (k.keyCode != KeyEvent.KEYCODE_DPAD_DOWN) false + else { + if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) { + if (open) runCatching { chipFocus[tab].requestFocus() } + else if (downOpens) open = true + else runCatching { lipFocus.requestFocus() } + } + true + } + }, + ) { + button { + if (launching) return@button + if (open || shown) { launching = true; open = false } else onPrimary() + } + } + // Gone with the words while the sheet is up; nothing a pad or a finger can reach then. + val besideShown = !(open || shown) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.graphicsLayer { alpha = 1f - sink.value } + .focusProperties { canFocus = besideShown }, + ) { if (besideShown || sink.value < 0.99f) beside() } + } + } + + // The lip, under Play: stretched by the pull, gone as the sheet lifts. + val r0 = restRect + // A lip a pad can reach stays composed (unseen at rest) so Down always has it to land on. + if (r0 != null && (lipShow.value > 0.01f || pull.value > 0.5f || (!downOpens && !open && !shown))) { + var handleW by remember { mutableStateOf(0f) } + val lipH = 22.dp + pull.value.dp + val lift = { if (!open) open = true } + Box( + Modifier + .offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) } + .onSizeChanged { handleW = it.width.toFloat() } + .graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) } + // 44dp to touch; the tab itself is the top 22. + .heightIn(min = 44.dp) + .semantics { contentDescription = settingsLabel } + .then( + if (downOpens) Modifier.focusProperties { canFocus = false } + else Modifier.focusRequester(lipFocus).onFocusChanged { lipFocused = it.isFocused }.controllerConfirm(onClick = lift), + ) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = lift), + ) { + Box(Modifier.glideBorder(lipFocused, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp), LocalPalette.current.signal)) { + Handle(lipH) + } + } + } + } +} + +/** + * The sheet's handle, tucked under Play: "Settings" and a chevron that bobs up every so often, + * saying there is a drawer to pull up. Still with animations off. + */ +@Composable +private fun Handle(height: Dp) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + val still = Motion.scale == 0f + val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle") + // A quick lift and settle, then a rest: a nudge, not a pulse. + val lift by bob.animateFloat( + 0f, 0f, + androidx.compose.animation.core.infiniteRepeatable( + androidx.compose.animation.core.keyframes { + durationMillis = 2400 + 0f at 0 + 1f at 180 using androidx.compose.animation.core.FastOutSlowInEasing + -0.25f at 420 + 0f at 620 + }, + ), + label = "handleBob", + ) + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.height(height).clip(shape).background(colors.surface) + .drawBorderSidesAndFoot(pal.line, 12.dp) + .padding(start = 14.dp, end = 10.dp), + ) { + Text(stringResource(R.string.drawer_page_settings), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1) + Icon( + Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant, + modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift }, + ) + } +} + +/** A 1dp outline down the sides and along the foot, none along the top (the lip tucks under Play). */ +private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier = + this.drawWithContent { + drawContent() + val c = corner.toPx() + val sw = 1.dp.toPx() + val p = Path().apply { + moveTo(sw / 2, 0f) + lineTo(sw / 2, size.height - c) + arcTo(Rect(sw / 2, size.height - 2 * c + sw / 2, 2 * c, size.height - sw / 2), 180f, -90f, false) + lineTo(size.width - c, size.height - sw / 2) + arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false) + lineTo(size.width - sw / 2, 0f) + } + drawPath(p, color, style = Stroke(sw)) + } + +/** + * The sheet's chips: equal capsules with one selection gliding under them (the stores' chip row), + * LB and RB a chip at a time, a tap or A on a far one a hop as a drop. [first] takes focus as the + * sheet opens. Too narrow for equal chips, the row scrolls. + */ +@Composable +internal fun SheetChips(labels: List, selected: Int, focus: List? = null, onPick: (Int) -> Unit) { + val pal = LocalPalette.current + val bounds = remember { mutableStateMapOf() } + val tint = rememberUpdatedState(pal.signal) + // Each chip its own requester: one moved from chip to chip under a focused chip lost the ring. + val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } } + var within by remember { mutableStateOf(false) } + // LB / RB with a pad on the chips: focus goes with the selection. + LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } } + BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) { + val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp + val scroll = rememberScrollState() + // Room inside the scroll for the focus ring, which the scroll's clip would otherwise cut. + Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) { + ChipGlide(selected, bounds[selected], tint) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = if (equal) Modifier.fillMaxWidth() else Modifier) { + labels.forEachIndexed { i, label -> + val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp)) + .onGloballyPositioned { bounds[i] = it.boundsInParent() } + .focusRequester(chips[i]) + SheetChip(label, i == selected, m) { onPick(i) } + } + } + } + } +} + +@Composable +private fun SheetChip(label: String, on: Boolean, modifier: Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + Box( + contentAlignment = Alignment.Center, + modifier = modifier.heightIn(min = 44.dp).clip(shape) + .background(if (on) Color.Transparent else colors.surfaceVariant.copy(alpha = 0.5f)) + .glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text(label, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** + * A read-only line that is a link: [title], [value] under it, and "[link] ↗" at the right in the + * signal blue - not a picker, so no value box. A (or a tap) follows it. [id] is its focus key. + */ +@Composable +internal fun LinkRow(title: String, value: String, link: String, id: String, arrow: String = "↗", onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().paneItem(id).hopSource(id).heightIn(min = 56.dp).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.10f) else Color.Transparent) + .glideBorder(hot, shape, pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + ) { + Column(Modifier.weight(1f).padding(end = 12.dp)) { + Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) + Text(value, fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text("$link $arrow", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index b348ff5ba..5aaf9f841 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned import com.droiddeck.launcher.core.FexPreset import com.droiddeck.launcher.runtime.DeckyManager import com.droiddeck.launcher.session.SessionPrefs @@ -152,293 +154,169 @@ class ModeSettingsActions( val onDismiss: () -> Unit, ) -private enum class ModeSettingsTab(val label: Int) { - DISPLAY(R.string.drawer_page_display), - CONTROLS(R.string.drawer_page_controls), - GAMES(R.string.game_settings_title), - AUDIO(R.string.mode_audio), - SESSION(R.string.mode_session), - STEAM(R.string.mode_tab_steam), +/** + * The chips of a mode's lifted sheet, in order: Steam's six; the desktop's Display, Controls and + * Session (its Files chip is the Desktop page's own). + */ +internal fun modeSheetTabs(steam: Boolean): List = + if (steam) listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY, SheetTab.STEAM) + else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION) + +/** A sheet tab's chip label. */ +@Composable +internal fun sheetTabLabel(tab: String): String = when (tab) { + SheetTab.DISPLAY -> stringResource(R.string.drawer_page_display) + SheetTab.CONTROLS -> stringResource(R.string.drawer_page_controls) + SheetTab.AUDIO -> stringResource(R.string.mode_audio) + SheetTab.SESSION -> stringResource(R.string.mode_session) + SheetTab.LIBRARY -> stringResource(R.string.stores_tab_library) + SheetTab.STEAM -> stringResource(R.string.mode_tab_steam) + else -> stringResource(R.string.setup_tool_files) } +/** + * Rows the front end adds to a sheet's tabs: frame generation (Display), Performance (Session) and + * the Graphics drivers link to Components (Display, given the drivers' summary). + */ +class SheetExtras( + val frameGen: @Composable () -> Unit = {}, + val performance: @Composable () -> Unit = {}, + val drivers: @Composable (summary: String?) -> Unit = {}, +) + +/** One tab of a mode's lifted sheet: the rows the settings page had, grouped by what they are for. */ @Composable -fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { +fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras: SheetExtras = SheetExtras()) { 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) } - val tabs = if (steam) ModeSettingsTab.entries else listOf( - ModeSettingsTab.DISPLAY, ModeSettingsTab.CONTROLS, ModeSettingsTab.SESSION, - ) - val tab = tabs[tabIndex] - val scrolls = tabs.map { androidx.compose.foundation.rememberScrollState() } - val tabFocus = remember(s.mode) { tabs.map { androidx.compose.ui.focus.FocusRequester() } } - val firstChip = remember { androidx.compose.ui.focus.FocusRequester() } - fun selectTab(index: Int) { host.open = null; tabIndex = index } - androidx.compose.runtime.LaunchedEffect(steam, tabIndex) { - androidx.compose.runtime.withFrameNanos { } - runCatching { - if (tab == ModeSettingsTab.DISPLAY) firstChip.requestFocus() - else tabFocus[tabIndex].requestFocus() + if (tab == SheetTab.DISPLAY) { + val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution) + SettingsGroup(stringResource(R.string.display_session)) { + ResolutionRow(host, s.resolution, s.panelSize, a.onResolution) + if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) || + com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) { + val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize) + ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit), + stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable), + listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)), + s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9) + } + } + SettingsGroup(stringResource(R.string.display_image_scaling)) { + ChoiceRow( + host, "upscaler", stringResource(R.string.display_filter), null, + SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler, + note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler, + ) + if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow( + stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5, + format = { "$it%" }, onChange = a.onUpscaleSharpness, + ) + } + SettingsGroup(stringResource(R.string.display_frame_rate)) { + ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session), + SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit) + } + SettingsGroup(stringResource(R.string.mode_hdr)) { + ToggleRow( + host, "hdr", stringResource(R.string.mode_hdr10), + s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) } + ?: stringResource(R.string.mode_hdr_restart), + checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr, + ) + } + extras.frameGen() + extras.drivers(s.gpuDrivers) + } + if (tab == SheetTab.CONTROLS) { + SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { + ChoiceRow( + host, "touch", stringResource(R.string.mode_touch), null, + listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, + note = stringResource(R.string.mode_touch_note), + onPick = a.onTouch, + ) + if (steam && s.oscMode != null) ChoiceRow( + host, "osc", stringResource(R.string.mode_osc), null, + listOf( + SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), + SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), + SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), + SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), + SessionPrefs.OSC_NEVER to stringResource(R.string.common_never), + ), s.oscMode, + note = stringResource(R.string.mode_osc_note), onPick = a.onOsc, + ) + if (steam && s.steamController != null) ChoiceRow( + host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint), + listOf( + SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck), + SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam), + SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4), + SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense), + SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360), + ), s.steamController, + note = stringResource(R.string.mode_controller_note), + onPick = a.onSteamController, + ) + if (steam && s.unfoldedControls != null) ToggleRow( + host, "unfolded-controls", stringResource(R.string.unfolded_controls), + stringResource(R.string.unfolded_controls_hint), + s.unfoldedControls, onChange = a.onUnfoldedControls, + ) + if (steam) ChoiceRow( + host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)), + listOf( + false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), + true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU), + ), s.backActionsInverted, onPick = a.onBackActionsInverted, + ) } } - SettingsPage( - host, - title = null, - onBack = a.onDismiss, - scroll = scrolls[tabIndex], - modifier = Modifier.bumpers( - onPrevious = { selectTab((tabIndex + tabs.size - 1) % tabs.size) }, - onNext = { selectTab((tabIndex + 1) % tabs.size) }, - ), - header = { - TabStrip(tabs.map { stringResource(it.label) }, tabIndex, ::selectTab, - modifier = Modifier.fillMaxWidth(), focusRequesters = tabFocus) - }, - ) { - if (tab == ModeSettingsTab.DISPLAY) { - val displaySize = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(s.panelSize, s.resolution) - SettingsGroup(stringResource(R.string.display_session)) { - ResolutionRow(host, s.resolution, s.panelSize, a.onResolution, - chipModifier = Modifier.focusRequester(firstChip)) - if (s.stretch16x9 != null && (com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(s.panelSize) || - com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize))) { - val canStretch = com.droiddeck.launcher.session.SessionDisplay.canStretch16x9(displaySize) - ChoiceRow(host, "game-fit", stringResource(R.string.display_game_fit), - stringResource(if (canStretch) R.string.display_game_fit_hint else R.string.display_game_fit_unavailable), - listOf(false to stringResource(R.string.display_preserve), true to stringResource(R.string.display_stretch)), - s.stretch16x9 && canStretch, enabled = canStretch, onPick = a.onStretch16x9) - } - } - SettingsGroup(stringResource(R.string.display_image_scaling)) { - ChoiceRow( - host, "upscaler", stringResource(R.string.display_filter), null, - SessionPrefs.upscalerChoices(LocalContext.current), s.upscaler, - note = stringResource(R.string.display_filter_note), onPick = a.onUpscaler, - ) - if (SessionPrefs.upscalerHasSharpness(s.upscaler)) SliderRow( - stringResource(R.string.display_sharpness), null, s.upscaleSharpness, 0..100, step = 5, - format = { "$it%" }, onChange = a.onUpscaleSharpness, - ) - } - SettingsGroup(stringResource(R.string.display_frame_rate)) { - ChoiceRow(host, "fps", stringResource(R.string.mode_fps), stringResource(R.string.common_applies_next_session), - SessionPrefs.fpsLimitChoices(LocalContext.current), s.fpsLimit, note = stringResource(R.string.mode_fps_note), onPick = a.onFpsLimit) - } - SettingsGroup(stringResource(R.string.mode_hdr)) { - ToggleRow( - host, "hdr", stringResource(R.string.mode_hdr10), - s.hdrReason?.let { stringResource(R.string.mode_hdr_unavailable, it) } - ?: stringResource(R.string.mode_hdr_restart), - checked = s.hdr && s.hdrReason == null, enabled = s.hdrReason == null, onChange = a.onHdr, - ) - } - SettingsGroup(stringResource(R.string.mode_drivers)) { - SettingsRow(stringResource(R.string.mode_gpu_drivers), stringResource(R.string.mode_gpu_drivers_hint)) { - ValueChip(s.gpuDrivers ?: stringResource(R.string.common_auto), open = false) { a.onGpuDrivers() } - } - } - } - if (tab == ModeSettingsTab.CONTROLS) { - SettingsGroup(if (steam) stringResource(R.string.mode_touch_controls) else stringResource(R.string.mode_touch)) { - ChoiceRow( - host, "touch", stringResource(R.string.mode_touch), null, - listOf(SessionPrefs.TOUCH_AUTO to stringResource(R.string.common_auto), SessionPrefs.TOUCH_PAD to stringResource(R.string.mode_touch_touchpad), SessionPrefs.TOUCH_DIRECT to stringResource(R.string.mode_touch_direct), SessionPrefs.TOUCH_OFF to stringResource(R.string.widgets_off)), s.touchMode, - note = stringResource(R.string.mode_touch_note), - onPick = a.onTouch, - ) - if (steam && s.oscMode != null) ChoiceRow( - host, "osc", stringResource(R.string.mode_osc), null, - listOf( - SessionPrefs.OSC_AUTO to stringResource(R.string.common_auto), - SessionPrefs.OSC_ALWAYS to stringResource(R.string.common_always), - SessionPrefs.OSC_STEAM_TOUCH to stringResource(R.string.osc_steam_touch), - SessionPrefs.OSC_STEAM_QAM to stringResource(R.string.mode_osc_qam), - SessionPrefs.OSC_NEVER to stringResource(R.string.common_never), - ), s.oscMode, - note = stringResource(R.string.mode_osc_note), onPick = a.onOsc, - ) - if (steam && s.steamController != null) ChoiceRow( - host, "controller", stringResource(R.string.mode_controller), stringResource(R.string.mode_controller_hint), - listOf( - SessionPrefs.CONTROLLER_DECK to stringResource(R.string.mode_controller_deck), - SessionPrefs.CONTROLLER_STEAM to stringResource(R.string.mode_controller_steam), - SessionPrefs.CONTROLLER_DS4 to stringResource(R.string.mode_controller_ds4), - SessionPrefs.CONTROLLER_DUALSENSE to stringResource(R.string.mode_controller_dualsense), - SessionPrefs.CONTROLLER_XBOX360 to stringResource(R.string.mode_controller_x360), - ), s.steamController, - note = stringResource(R.string.mode_controller_note), - onPick = a.onSteamController, - ) - if (steam && s.unfoldedControls != null) ToggleRow( - host, "unfolded-controls", stringResource(R.string.unfolded_controls), - stringResource(R.string.unfolded_controls_hint), - s.unfoldedControls, onChange = a.onUnfoldedControls, - ) - if (steam) ChoiceRow( - host, "back-actions", stringResource(R.string.mode_back), stringResource(SessionPrefs.backActionsOrder(s.backActionsInverted)), - listOf( - false to stringResource(SessionPrefs.BACK_MENU_THEN_QAM), - true to stringResource(SessionPrefs.BACK_QAM_THEN_MENU), - ), s.backActionsInverted, onPick = a.onBackActionsInverted, - ) - } - } - if (tab == ModeSettingsTab.SESSION) { - SettingsGroup(stringResource(R.string.mode_session)) { - ChoiceRow( - host, "suspend", stringResource(R.string.mode_suspend), - stringResource(R.string.mode_suspend_hint), - listOf( - SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto), - SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual), - SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never), - ) + if (steam) listOf( - SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native), - ) else emptyList(), - s.suspendPolicy, - note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note), - onPick = a.onSuspendPolicy, - ) - if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow( - host, "background-downloads", stringResource(R.string.mode_background_downloads), - stringResource(R.string.mode_background_downloads_hint), - s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground, - ) - } - if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { - ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, - s.pipAutoEnter, onChange = a.onPipAutoEnter) - } - if (steam) SettingsGroup(stringResource(R.string.mode_startup)) { - ToggleRow( - host, "steam-startup", stringResource(R.string.mode_steam_startup), - stringResource(R.string.mode_steam_startup_hint), - s.runSteamAtStartup, onChange = a.onRunSteamAtStartup, - ) - } - } - if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_decky)) { - val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null && - s.deckyInstalled != s.deckyLatestRelease.tag - val status = when { - s.deckyStage != null -> s.deckyStage - s.deckyChecking -> stringResource(R.string.mode_decky_checking) - s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag) - s.deckyInstalled == null -> stringResource(R.string.mode_decky_none) - s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled) - updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag) - else -> stringResource(R.string.mode_decky_current, s.deckyInstalled) - } - SettingsRow(stringResource(R.string.mode_decky_loader), status) { - val action = when { - s.deckyStage != null -> stringResource(R.string.common_working) - s.deckyChecking -> stringResource(R.string.common_checking) - s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest) - s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update) - else -> stringResource(R.string.common_check) - } - Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) { - SecondaryButton( - action, - enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning, - ) { - if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck() - else s.deckyLatestRelease?.let(a.onDeckyInstall) - } - if (s.deckyInstalled != null) SecondaryButton( - stringResource(R.string.common_uninstall), - enabled = s.deckyStage == null && !s.deckySessionRunning, - ) { confirmDeckyRemoval = true } - } - } - if (s.deckyInstalled != null) ToggleRow( - host, "decky-enabled", stringResource(R.string.mode_decky), - if (s.deckyEnabled) stringResource(R.string.mode_decky_on) - else stringResource(R.string.mode_decky_off), - s.deckyEnabled, - enabled = !s.deckySessionRunning && s.deckyStage == null, - onChange = a.onDeckyEnabled, - ) - SettingsRow( - stringResource(R.string.mode_decky_plugins), - stringResource(R.string.mode_decky_plugins_hint), - ) { - SecondaryButton( - if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip), - enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning, - onClick = a.onPickDeckyPluginZip, - ) - } - if (s.deckyStage != null && s.deckyPercent >= 0) { - LinearProgressIndicator( - progress = { s.deckyPercent / 100f }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp), - ) - } - } - if (steam && tab == ModeSettingsTab.STEAM) SettingsGroup(stringResource(R.string.mode_client)) { - ToggleRow( - host, "mangoapp", stringResource(R.string.mode_mangoapp), - stringResource(R.string.mode_mangoapp_hint), - s.mangoapp, onChange = a.onMangoapp, - ) - SettingsRow( - stringResource(R.string.mode_steam_repair), - stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint), - ) { - SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair) - } - } - if (steam && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { - val hint = when { - s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) - !s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied) - else -> stringResource(R.string.mode_wifi_discovery_hint) - } - SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) { - Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { - if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) { - SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings) - } - ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on -> - host.open = null - if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false) - } - } - } - } - if (steam && tab == ModeSettingsTab.GAMES && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) { - if (s.syncBackend != null) SettingsRow( - stringResource(R.string.sync_backend_title), - stringResource(R.string.sync_backend_hint), - ) { - SegmentedTabs( - listOf( - SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync), - SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync), - SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync), - SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver), - ), - s.syncBackend, - ) { id -> host.open = null; a.onSyncBackend(id) } - } - ChoiceRow( - host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch), - FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset, - note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset, - ) - ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), s.forceSsbs, onChange = a.onForceSsbs) - GameEnvironmentRow() - - } - if (steam && tab == ModeSettingsTab.AUDIO && s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) { + if (tab == SheetTab.AUDIO && steam) { + if (s.directAudio != null && s.mic != null) SettingsGroup(stringResource(R.string.mode_audio)) { ToggleRow(host, "da", stringResource(R.string.mode_directaudio), stringResource(R.string.mode_directaudio_hint), s.directAudio, onChange = a.onDirectAudio) ToggleRow(host, "mic", stringResource(R.string.mode_mic), stringResource(R.string.mode_mic_hint), s.mic, onChange = a.onMic) } - if (steam && tab == ModeSettingsTab.GAMES && s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) { + } + if (tab == SheetTab.SESSION) { + SettingsGroup(stringResource(R.string.mode_session)) { + ChoiceRow( + host, "suspend", stringResource(R.string.mode_suspend), + stringResource(R.string.mode_suspend_hint), + listOf( + SessionPrefs.SUSPEND_AUTO to stringResource(R.string.common_auto), + SessionPrefs.SUSPEND_MANUAL to stringResource(R.string.mode_suspend_manual), + SessionPrefs.SUSPEND_NEVER to stringResource(R.string.common_never), + ) + if (steam) listOf( + SessionPrefs.SUSPEND_NATIVE to stringResource(R.string.mode_suspend_native), + ) else emptyList(), + s.suspendPolicy, + note = stringResource(if (steam) R.string.mode_suspend_steam_note else R.string.mode_suspend_note), + onPick = a.onSuspendPolicy, + ) + if (steam && s.suspendPolicy != SessionPrefs.SUSPEND_NEVER) ToggleRow( + host, "background-downloads", stringResource(R.string.mode_background_downloads), + stringResource(R.string.mode_background_downloads_hint), + s.steamDownloadsInBackground, onChange = a.onSteamDownloadsInBackground, + ) + } + if (s.pipSupported) SettingsGroup(stringResource(R.string.pip_title)) { + ToggleRow(host, "pip-auto", stringResource(R.string.pip_auto), null, + s.pipAutoEnter, onChange = a.onPipAutoEnter) + } + if (steam) SettingsGroup(stringResource(R.string.mode_startup)) { + ToggleRow( + host, "steam-startup", stringResource(R.string.mode_steam_startup), + stringResource(R.string.mode_steam_startup_hint), + s.runSteamAtStartup, onChange = a.onRunSteamAtStartup, + ) + } + extras.performance() + } + if (tab == SheetTab.LIBRARY && steam) { + if (s.gameStorage != null) SettingsGroup(stringResource(R.string.mode_storage)) { val custom = s.gameStorage.isNotEmpty() && s.gameStorage != "off" && s.storageOptions.none { it.path == s.gameStorage } // Each choice: its path, its menu line, the chip's shorter line, and the label it is saved with. class StorageChoice(val path: String, val label: String, val chip: String, val saved: String) @@ -481,6 +359,96 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } } + if (tab == SheetTab.STEAM && steam) { + SettingsGroup(stringResource(R.string.mode_decky)) { + val updateAvailable = s.deckyInstalled != null && s.deckyLatestRelease != null && + s.deckyInstalled != s.deckyLatestRelease.tag + val status = when { + s.deckyStage != null -> s.deckyStage + s.deckyChecking -> stringResource(R.string.mode_decky_checking) + s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_ready, s.deckyLatestRelease.tag) + s.deckyInstalled == null -> stringResource(R.string.mode_decky_none) + s.deckyLatestRelease == null -> stringResource(R.string.mode_decky_installed, s.deckyInstalled) + updateAvailable -> stringResource(R.string.mode_decky_update, s.deckyLatestRelease.tag) + else -> stringResource(R.string.mode_decky_current, s.deckyInstalled) + } + SettingsRow(stringResource(R.string.mode_decky_loader), status) { + val action = when { + s.deckyStage != null -> stringResource(R.string.common_working) + s.deckyChecking -> stringResource(R.string.common_checking) + s.deckyInstalled == null && s.deckyLatestRelease != null -> stringResource(R.string.mode_decky_install_latest) + s.deckyInstalled != null && updateAvailable -> stringResource(R.string.common_update) + else -> stringResource(R.string.common_check) + } + Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp)) { + SecondaryButton( + action, + enabled = s.deckyStage == null && !s.deckyChecking && !s.deckySessionRunning, + ) { + if (s.deckyLatestRelease == null || (s.deckyInstalled != null && !updateAvailable)) a.onDeckyCheck() + else s.deckyLatestRelease?.let(a.onDeckyInstall) + } + if (s.deckyInstalled != null) DeckyUninstall(enabled = s.deckyStage == null && !s.deckySessionRunning, onUninstall = a.onDeckyUninstall) + } + } + if (s.deckyInstalled != null) ToggleRow( + host, "decky-enabled", stringResource(R.string.mode_decky), + if (s.deckyEnabled) stringResource(R.string.mode_decky_on) + else stringResource(R.string.mode_decky_off), + s.deckyEnabled, + enabled = !s.deckySessionRunning && s.deckyStage == null, + onChange = a.onDeckyEnabled, + ) + SettingsRow( + stringResource(R.string.mode_decky_plugins), + stringResource(R.string.mode_decky_plugins_hint), + ) { + SecondaryButton( + if (s.deckyStage != null && s.deckyDownloadingBinary) stringResource(R.string.mode_decky_plugin_downloading) else stringResource(R.string.mode_decky_plugin_install_zip), + enabled = s.deckyInstalled != null && s.deckyStage == null && !s.deckySessionRunning, + onClick = a.onPickDeckyPluginZip, + ) + } + if (s.deckyStage != null && s.deckyPercent >= 0) { + LinearProgressIndicator( + progress = { s.deckyPercent / 100f }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp), + ) + } + } + SettingsGroup(stringResource(R.string.mode_client)) { + ToggleRow( + host, "mangoapp", stringResource(R.string.mode_mangoapp), + stringResource(R.string.mode_mangoapp_hint), + s.mangoapp, onChange = a.onMangoapp, + ) + SettingsRow( + stringResource(R.string.mode_steam_repair), + stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint), + ) { + SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair) + } + } + if (s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) { + val hint = when { + s.wifiDiscovery && !s.wifiDiscoveryLocation -> stringResource(R.string.mode_wifi_location_off) + !s.wifiDiscoveryPermission && s.wifiDiscoveryAsked -> stringResource(R.string.mode_wifi_permission_denied) + else -> stringResource(R.string.mode_wifi_discovery_hint) + } + SettingsRow(stringResource(R.string.mode_wifi_discovery), hint) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) { + if (s.wifiDiscoveryBlocked || (s.wifiDiscovery && !s.wifiDiscoveryLocation)) { + SecondaryButton(stringResource(R.string.mode_wifi_open_settings), onClick = a.onWifiDiscoverySettings) + } + ToggleSwitch(s.wifiDiscovery, label = stringResource(R.string.mode_wifi_discovery)) { on -> + host.open = null + if (on) explainWifiDiscovery = true else a.onWifiDiscovery(false) + } + } + } + } + } if (explainWifiDiscovery) AlertDialog( onDismissRequest = { explainWifiDiscovery = false }, title = { Text(stringResource(R.string.mode_wifi_explain_title)) }, @@ -494,15 +462,66 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { TextButton(onClick = { explainWifiDiscovery = false }) { Text(stringResource(R.string.common_cancel)) } }, ) - if (confirmDeckyRemoval) AlertDialog( - onDismissRequest = { confirmDeckyRemoval = false }, - title = { Text(stringResource(R.string.mode_decky_remove_title)) }, - text = { Text(stringResource(R.string.mode_decky_remove_text)) }, - confirmButton = { - TextButton(onClick = { confirmDeckyRemoval = false; a.onDeckyUninstall() }) { Text(stringResource(R.string.common_uninstall)) } - }, - dismissButton = { TextButton(onClick = { confirmDeckyRemoval = false }) { Text(stringResource(R.string.common_cancel)) } }, +} + +/** + * The options that apply to every Proton - how Wine syncs, FEX's preset, Force SSBS and the games' + * environment - for the Components page's Proton layer. + */ +@Composable +internal fun ProtonOptions( + syncBackend: String, fexPreset: String, forceSsbs: Boolean, + onSyncBackend: (String) -> Unit, onFexPreset: (String) -> Unit, onForceSsbs: (Boolean) -> Unit, +) { + val host = rememberMenuHost() + SettingsRow(stringResource(R.string.sync_backend_title), stringResource(R.string.sync_backend_hint)) { + SegmentedTabs( + listOf( + SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync), + SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync), + SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync), + SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver), + ), + syncBackend, + ) { id -> host.open = null; onSyncBackend(id) } + } + ChoiceRow( + host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch), + FexPreset.all.map { it.id to stringResource(it.label) }, fexPreset, + note = stringResource(FexPreset.byId(fexPreset).detail), onPick = onFexPreset, ) + ToggleRow(host, "ssbs", stringResource(R.string.force_ssbs_title), stringResource(R.string.force_ssbs_hint), forceSsbs, onChange = onForceSsbs) + GameEnvironmentRow() +} + +/** + * Uninstall for Decky Loader: are you sure steps out of it in red, Cancel first, so a stray A + * costs nothing. + */ +@Composable +private fun DeckyUninstall(enabled: Boolean, onUninstall: () -> Unit) { + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.common_uninstall) + val title = stringResource(R.string.mode_decky_remove_title) + val note = stringResource(R.string.mode_decky_remove_text) + val cancel = stringResource(R.string.common_cancel) + var at by remember { mutableStateOf(null) } + androidx.compose.foundation.layout.Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Left, accent = colors.error, pillCorner = 12.dp, items = 3, + handle = { Text(label, fontSize = 15.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(title) + Text(note, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp, androidx.compose.ui.Alignment.End), modifier = Modifier.stepItem(2)) { + StepChoice(cancel, enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); onUninstall() } + } + }) + } + } } /** Width × height for the session, with the common handheld shapes one tap away. */ diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt b/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt index a2b24b594..91a8eaa60 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PageFlood.kt @@ -44,9 +44,38 @@ import kotlin.math.sin // Play (LaunchFlood): the control stretches out over the pane on loose springs, the far edges // first, and the page rises into it once it is covered. Back draws it down into the control again. -/** Where a page opened from: the control's bounds and corner radius, in px. */ -internal class Origin(val bounds: Rect, val corner: Float) { - fun translate(by: Offset) = Origin(bounds.translate(by), corner) +/** + * Where a page opened from: the control's bounds and corner radius, in px, and the rail item that + * was showing it ([owner]), so the page only draws back into it when that content returns. [art] + * is the control's picture for the tile (a drawable id, or an image Coil loads), if it has one. + */ +internal class Origin(val bounds: Rect, val corner: Float, val owner: String? = null, val art: Any? = null) { + fun translate(by: Offset) = Origin(bounds.translate(by), corner, owner, art) + fun ownedBy(key: String) = Origin(bounds, corner, key, art) +} + +/** How a page that opened from a control leaves. */ +internal enum class PageExit { + /** Back into the control it came from, which is on screen again. */ + Drain, + /** Out through a link into another section, which takes the page there itself. */ + Hop, + /** Away with the pane, as any page goes: the control it came from is not coming back. */ + Sink, +} + +/** Why a page is leaving: an ordinary change of page, or a link into another section. */ +internal enum class PageLeave { Ordinary, Link } + +/** + * The exit for a page opened from a control on [owner]'s content, leaving for [incoming]. It only + * draws back into the control when the content that control belongs to is what comes back; a page + * left for another rail item or another page drained into a control that was no longer there. + */ +internal fun pageExit(owner: String?, incoming: String, leave: PageLeave): PageExit = when { + owner != null && incoming == owner -> PageExit.Drain + leave == PageLeave.Link -> PageExit.Hop + else -> PageExit.Sink } internal object PageOrigin { @@ -70,6 +99,7 @@ internal const val PAGE_RETURN_MS = 900 * [content], a page that opened from the control at [from] (in this element's coordinates): the * control's tile stretching over it until covered, then the page rising in as the tile fades. * Once [leaving], the reverse: the tile comes back over the page and draws down into the control. + * A page that [sinks] instead goes with the pane and draws no tile at all. * The focus ring stays out of it until it is done. */ @Composable @@ -78,6 +108,7 @@ internal fun PageFlood( leaving: Boolean, /** What the tile shows instead of the control's fill: a game card's art, cropped to the tile as it grows. */ art: Painter? = null, + sinks: Boolean = false, content: @Composable () -> Unit, ) { val pal = LocalPalette.current @@ -87,7 +118,14 @@ internal fun PageFlood( val shown = remember { Animatable(0f) } val tile = remember { Animatable(1f) } var size by remember { mutableStateOf(Size.Zero) } - LaunchedEffect(size != Size.Zero, leaving) { + LaunchedEffect(size != Size.Zero, leaving, sinks) { + if (sinks) { + // Whatever the flood in was doing, the page is shown whole and leaves with the pane. + tile.snapTo(0f) + shown.snapTo(1f) + glide?.hidden = false + return@LaunchedEffect + } if (size == Size.Zero) return@LaunchedEffect glide?.hidden = true val b = from.bounds diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt deleted file mode 100644 index 3515f2180..000000000 --- a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.droiddeck.launcher.ui - -import com.droiddeck.launcher.R -import androidx.compose.ui.res.stringResource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -class ProtonRow(val id: String, val name: String, val installed: String?, val queued: Boolean) - -@Composable -fun ProtonPage( - rows: List, - busyId: String?, - stage: String?, - percent: Int, - runtimeReady: Boolean, - sessionRunning: Boolean, - onInstall: (String) -> Unit, - onCancel: (String) -> Unit, - onRemove: (String) -> Unit, - onBack: () -> Unit, -) { - val host = rememberMenuHost() - val colors = MaterialTheme.colorScheme - SettingsPage( - host, - title = stringResource(R.string.setup_tool_protons), - eyebrow = stringResource(R.string.setup_title), - lede = stringResource(R.string.proton_lede), - onBack = onBack, - ) { - if (!runtimeReady) Text( - stringResource(R.string.content_needs_runtime), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), - ) - if (sessionRunning) Text( - stringResource(R.string.proton_stop_first), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), - ) - val compatible = stringResource(R.string.steam_compat_label) - Text( - stringResource(R.string.proton_compatible_note, compatible), - fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), - ) - SettingsGroup(stringResource(R.string.proton_available)) { - for (row in rows) { - SettingsRow( - row.name, - when { - row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible) - row.queued -> stringResource(R.string.proton_queued) - else -> stringResource(R.string.proton_not_installed) - }, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - when { - row.installed != null -> SecondaryButton(stringResource(R.string.store_remove), enabled = busyId == null && runtimeReady && !sessionRunning) { onRemove(row.id) } - busyId != null -> SecondaryButton(if (busyId == row.id) stringResource(R.string.store_installing) else stringResource(R.string.store_install), enabled = false) {} - else -> SecondaryButton(stringResource(R.string.proton_install_now), enabled = runtimeReady && !sessionRunning) { onInstall(row.id) } - } - if (row.queued && row.installed == null && busyId == null) { - SecondaryButton(stringResource(R.string.proton_cancel_queued), enabled = !sessionRunning) { onCancel(row.id) } - } - } - } - if (busyId == row.id) Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 10.dp)) { - Text( - if (stage != null && percent >= 0) stringResource(R.string.store_busy_percent_short, stage, percent) else stage ?: stringResource(R.string.store_starting), - fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 5.dp), - ) - if (percent >= 0) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) - else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) - } - } - } - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 3aeb4e2fb..c94818f0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -423,6 +423,8 @@ fun ValueChip(text: String, open: Boolean, enabled: Boolean = true, modifier: Mo fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable ColumnScope.() -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current + // In a lifted sheet each group fades and rises in on its own beat. + Column(Modifier.fillMaxWidth().sheetPiece()) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 10.dp), @@ -432,6 +434,7 @@ fun SettingsGroup(title: String, compact: Boolean = false, content: @Composable Box(modifier = Modifier.weight(1f).height(1.dp).background(pal.line)) } Column(modifier = Modifier.fillMaxWidth().clip(GroupShape).background(colors.surface).border(1.dp, pal.line, GroupShape)) { content() } + } } /** diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt new file mode 100644 index 000000000..4d01f20da --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SharedMotion.kt @@ -0,0 +1,237 @@ +package com.droiddeck.launcher.ui + +import android.os.SystemClock +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.sin + +// The moves the stores made first, for any section: one selection shape that glides along a row +// (or down a column) of choices, and a pressed control that squashes into a dot and flies home. + +/** Which way a [ChipGlide]'s choices run. */ +internal enum class GlideAxis { Horizontal, Vertical } + +/** + * The selection under a row of chips: [at] (in the row) of chip [index], in [tint]. A move to the + * next chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a + * beat later on its trail spring - and a hop past a chip pinches it to a drop, carries it and + * opens it. [axis] runs it down a column instead (the rail, a stack of layers). [corner] is the + * chips' own corner; null draws a capsule. [fill] and [outline] are the resting alphas. + */ +@Composable +internal fun BoxScope.ChipGlide( + index: Int, + at: Rect?, + tint: State, + axis: GlideAxis = GlideAxis.Horizontal, + corner: Dp? = null, + fill: Float = 0.16f, + outline: Float = 0.7f, +) { + val density = androidx.compose.ui.platform.LocalDensity.current + // The two edges along the axis (left and right, or top and bottom), and the two across it. + val start = remember { Animatable(Float.NaN) } + val end = remember { Animatable(0f) } + val pinch = remember { Animatable(0f) } + val crossStart = remember { mutableStateOf(0f) } + val crossEnd = remember { mutableStateOf(0f) } + val was = remember { intArrayOf(-1) } + val vertical = axis == GlideAxis.Vertical + LaunchedEffect(index, at) { + val b = at ?: return@LaunchedEffect + val s = if (vertical) b.top else b.left + val e = if (vertical) b.bottom else b.right + crossStart.value = if (vertical) b.left else b.top + crossEnd.value = if (vertical) b.right else b.bottom + val from = was[0] + was[0] = index + if (start.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) { + start.snapTo(s); end.snapTo(e); pinch.snapTo(0f) + return@LaunchedEffect + } + coroutineScope { + if (abs(index - from) > 1) { + // Too far to stretch: pinch into a drop where it is, carry it over, open it there. + pinch.animateTo(1f, Motion.tw(90)) + val l = launch { start.animateTo(s, Motion.sp(0.7f, 380f)) } + val r = launch { end.animateTo(e, Motion.sp(0.7f, 380f)) } + l.join(); r.join() + pinch.animateTo(0f, Motion.sp(0.6f, 500f)) + } else { + val forward = index > from + val (lead, trail) = if (forward) end to start else start to end + val leadTo = if (forward) e else s + val trailTo = if (forward) s else e + launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) } + launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) } + } + } + } + Canvas(Modifier.matchParentSize()) { + if (start.value.isNaN() || crossEnd.value <= crossStart.value) return@Canvas + val p = pinch.value + val shape = glideShape(start.value, end.value, crossStart.value, crossEnd.value, p, with(density) { 6.dp.toPx() }, vertical) + val at0 = shape.topLeft + val size = shape.size + val round = minOf(size.width, size.height) / 2f + val r = CornerRadius(corner?.let { lerp(minOf(with(density) { it.toPx() }, round), round, p) } ?: round) + // A drop in flight is solid; on a chip it is the chip's tinted fill and outline. + drawRoundRect(tint.value.copy(alpha = lerp(fill, 0.9f, p)), at0, size, r) + if (outline > 0f) { + // Inside the chip's edge: a stroke on it would lose its outer half to a scrolling row's clip. + val w = with(density) { 1.dp.toPx() } + val inset = w / 2f + drawRoundRect( + tint.value.copy(alpha = outline * (1f - p)), at0 + Offset(inset, inset), Size(size.width - w, size.height - w), + CornerRadius((r.x - inset).coerceAtLeast(0f)), style = Stroke(w), + ) + } + } +} + +/** + * Where a glide's shape is: its edges along the axis at [start] and [end] (either order), across + * it from [crossStart] to [crossEnd], pinched [pinch] of the way (0 to 1) into a drop [drop] each + * side of its centre. [vertical] runs the axis down instead of across. + */ +internal fun glideShape(start: Float, end: Float, crossStart: Float, crossEnd: Float, pinch: Float, drop: Float, vertical: Boolean): Rect { + val a0 = minOf(start, end) + val a1 = maxOf(start, end) + val ca = (a0 + a1) / 2f + val cc = (crossStart + crossEnd) / 2f + val s0 = lerp(a0, ca - drop, pinch) + val s1 = lerp(a1, ca + drop, pinch) + val c0 = lerp(crossStart, cc - drop, pinch) + val c1 = lerp(crossEnd, cc + drop, pinch) + return if (vertical) Rect(c0, s0, c1, s1) else Rect(s0, c0, s1, c1) +} + +/** + * A dot on its way somewhere: from the control at [from] (root px) to the point [to] gives (root + * px, asked each frame, so a target that moves is followed). [color] null is the palette's signal + * blue. [onHalf] runs as it passes the top of its arc, [onLand] once it lands. + */ +internal class Flight( + val from: Rect, + val to: Density.() -> Offset?, + val color: Color? = null, + val onHalf: () -> Unit = {}, + val onLand: () -> Unit = {}, +) { + val id = SystemClock.uptimeMillis() * 1000 + (next++ % 1000) + + private companion object { var next = 0L } +} + +/** Dots flying anywhere outside the Stores page (which keeps its own): a hop, a swap, a toggle. */ +internal object Flights { + val all = mutableStateListOf() + + /** Sends [f], or with animations off just runs its ends. */ + fun fly(f: Flight) { + if (Motion.scale == 0f) { f.onHalf(); f.onLand(); return } + all += f + } +} + +/** [Flights] drawn over everything, in the space of an element at [origin] (root px). */ +@Composable +internal fun FlightsLayer(origin: Offset) { + for (f in Flights.all.toList()) key(f.id) { FlightDot(f, origin) { Flights.all.remove(f) } } +} + +/** + * One dot: the control squashes into a 14dp dot (width first, a little bloop), which then flies + * on an arc to its target, stretched along its path like the focus ring's drop. + */ +@Composable +internal fun FlightDot(f: Flight, origin: Offset, onDone: () -> Unit) { + val pal = LocalPalette.current + val color = f.color ?: pal.signal + val t = remember { Animatable(0f) } + LaunchedEffect(Unit) { + t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing)) + coroutineScope { + launch { delay(Motion.ms(150).toLong()); f.onHalf() } + t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing)) + } + f.onLand() + onDone() + } + Canvas(Modifier.fillMaxSize()) { + val r = 7.dp.toPx() + val from = f.from.translate(-origin) + val a = from.center + val b = (f.to(this) ?: return@Canvas) - origin + val v = t.value + if (v < 1f) { + // Squash: the width goes first, then the height, into the dot; a bloop at the end. + val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f)) + val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f)) + val k = 1f - 0.15f * sin(PI * v).toFloat() + drawRoundRect(color, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2)) + } else { + val p = v - 1f + val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx()) + val q = 1f - p + val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y) + val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y)) + val stretch = sin(PI * p).toFloat() + val angle = atan2(d.y, d.x) * 180f / PI.toFloat() + rotate(angle, pos) { + val w = 2 * r * (1f + 0.5f * stretch) + val h = 2 * r * (1f - 0.25f * stretch) + drawOval(color, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h)) + } + } + } +} + +/** + * A row that scrolls sideways fades out at an edge it has more past, instead of slicing a pill or + * a card through: the content's own alpha, so whatever is behind the row shows through. + */ +internal fun Modifier.edgeFade(scroll: androidx.compose.foundation.ScrollState, width: Dp = 28.dp): Modifier = + this.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val w = width.toPx().coerceAtMost(size.width / 3f) + if (scroll.value > 0) drawRect( + androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Transparent, 1f to Color.Black, startX = 0f, endX = w), + size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + if (scroll.value < scroll.maxValue) drawRect( + androidx.compose.ui.graphics.Brush.horizontalGradient(0f to Color.Black, 1f to Color.Transparent, startX = size.width - w, endX = size.width), + topLeft = Offset(size.width - w, 0f), size = Size(w, size.height), blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt b/app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt new file mode 100644 index 000000000..ac6364a2a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/SheetRows.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R + +// The rows the lifted sheets add to what the session settings already had: frame generation, +// Performance and the link to the GPU drivers on Components. + +/** Frame generation, moved here from Setup: it is how Play's games are drawn. */ +@Composable +internal fun FrameGenGroup(s: FrontEndState, a: FrontEndActions) { + val host = rememberMenuHost() + SettingsGroup(stringResource(R.string.frame_gen_title)) { + SettingsRow(stringResource(R.string.frame_gen_title), stringResource(R.string.frame_gen_hint), highlighted = host.open == "fg") { + Box { + ValueChip(s.frameGenLabel, host.open == "fg") { host.open = if (host.open == "fg") null else "fg" } + FrameGenMenu(host, s.frameGen, s.lossless, a.onFrameGenPick, a.onImportLossless) + } + } + } +} + +/** + * Performance (CPU core assignment), opened from the sheet's Session chip: its page floods out of + * this row, and Back draws it into the row again with the sheet lifted where it was. + */ +@Composable +internal fun PerformanceLink(a: FrontEndActions, rail: String, tab: String) { + val id = "sheet:performance" + val title = stringResource(R.string.setup_tool_performance) + SettingsGroup(title) { + LinkRow(title, stringResource(R.string.setup_tool_performance_hint), stringResource(R.string.store_open), id, arrow = "›") { + Hops.sources[id]?.let { at -> + PageOrigin.mark(Origin(at, 12f * Hops.density)) + Hops.restore = HopOrigin(rail, HOP_SURFACE_SHEET, tab, id, at, "", dest = rail, page = "performance") + } + a.onPerformance() + } + } +} + +/** + * The GPU drivers in use, read only, and a link to where they are set: A hops to Components with + * the GPU drivers layer chosen, and B there comes back to this row. + */ +@Composable +internal fun DriversLink(a: FrontEndActions, summary: String?, rail: String, back: String) { + val id = "sheet:gpu-drivers" + SettingsGroup(stringResource(R.string.mode_drivers)) { + LinkRow( + stringResource(R.string.sheet_graphics_drivers), summary ?: stringResource(R.string.common_auto), + stringResource(R.string.rail_components), id, + ) { + val at = Hops.sources[id] + if (at == null) a.onComponentsTab(GPU_TAB) + else Hops.go(HopOrigin(rail, HOP_SURFACE_SHEET, SheetTab.DISPLAY, id, at, back, dest = "components", page = "components")) { + a.onComponentsTab(GPU_TAB) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index c5e1a1cb7..5b65d2202 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.inset import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import coil.compose.rememberAsyncImagePainter @@ -82,41 +83,52 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) // came out differently from one read of the library to the next moved every capsule. val games = remember(s.steamGames) { s.steamGames.sortedWith(compareByDescending { it.lastPlayed }.thenBy { it.gameId }) } val narrow = LocalNarrowPane.current - val colors = MaterialTheme.colorScheme // Where the words and buttons sit, for the empty library's wandering mark to keep off. var words by remember { mutableStateOf(null) } - Box(modifier = modifier.fillMaxSize().clipToBounds()) { - when { - games.isEmpty() -> EmptyLibrary(avoid = words) { WallFade() } - games.size < WALL_MIN_GAMES -> CapsuleFan(games) - else -> { - CapsuleWall(games) - WallFade() + val mode = com.droiddeck.launcher.session.SessionService.MODE_STEAM + val tabs = modeSheetTabs(steam = true).map { it to sheetTabLabel(it) } + val back = stringResource(R.string.hop_from_play) + val extras = SheetExtras( + frameGen = { FrameGenGroup(s, a) }, + performance = { PerformanceLink(a, "steam", SheetTab.SESSION) }, + drivers = { summary -> DriversLink(a, summary, "steam", back) }, + ) + LiftHero( + key = "steam", tabs = tabs, + onOpen = a.onSteamSettings, onPrimary = a.onPlay, + modifier = modifier.clipToBounds(), + start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, + background = { + when { + // The mark wanders over this fade, so it sinks with the wall: drawn a little oversized, + // it still reaches the pane's edges at the sink's 0.95. + games.isEmpty() -> EmptyLibrary(avoid = words) { Box(Modifier.fillMaxSize().graphicsLayer { scaleX = 1.06f; scaleY = 1.06f }) { WallFade() } } + games.size < WALL_MIN_GAMES -> CapsuleFan(games) + else -> CapsuleWall(games) } - } - Column( - modifier = Modifier.align(Alignment.BottomStart) - .padding(start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, end = 20.dp) - .onPlaced { words = it.boundsInParent() }, - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + }, + scrim = { if (games.size >= WALL_MIN_GAMES) WallFade() }, + words = { + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.onPlaced { words = it.boundsInParent() }, + ) { Wordmark(if (narrow) 30.dp else 40.dp) // Said only when something stands between Play and Steam; a ready runtime needs no words. if (s.busy || !s.ready || (s.available != null && s.available != s.installed)) RuntimeChip(s) } - Spacer(Modifier.height(18.dp)) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - // Enabled without a runtime: the session's loading screen installs it first. - PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = a.onPlay) - Cog(size = 54.dp, onClick = a.onSteamSettings) - } - } - } + }, + button = { click -> + // Enabled without a runtime: the session's loading screen installs it first. + PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, large = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.testTag("play-steam"), onClick = click) + }, + rows = { tab -> a.modeSheet(mode, tab, extras) }, + ) } /** The wall fades out behind the words, and toward the bottom where the buttons sit. */ @Composable -private fun WallFade() { +internal fun WallFade() { val colors = MaterialTheme.colorScheme Box( Modifier.fillMaxSize().background( @@ -147,8 +159,12 @@ internal fun CapsuleWall( TiltedWall(games.size, driftMs, modifier) { i -> if (games.isEmpty()) blank else WallCapsule(artBrush(hueOf(games[i].name)), games[i].art) } } -/** One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. */ -internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null) +/** + * One capsule of a [TiltedWall]: its ground, the art laid over it once loaded, and an outline. A + * tile that is an app rather than a cover carries an [icon] instead (a drawable id or an image), + * drawn whole at the tile's centre, and a window's [bar] along its foot. + */ +internal class WallCapsule(val ground: Brush, val art: Any? = null, val edge: Color? = null, val icon: Any? = null, val bar: Color? = null) /** * [CapsuleWall]'s tilted, drifting columns for any [count] of capsules; [capsule] describes number i @@ -161,25 +177,28 @@ internal fun TiltedWall( count: Int, driftMs: Int, modifier: Modifier = Modifier, + /** One tile's size, the gap between tiles and their corner: portrait capsules unless a wall says otherwise. */ + capW: androidx.compose.ui.unit.Dp = 112.dp, + capH: androidx.compose.ui.unit.Dp = 168.dp, + gap: androidx.compose.ui.unit.Dp = 14.dp, + corner: androidx.compose.ui.unit.Dp = 10.dp, capsule: (index: Int) -> WallCapsule, ) { - val capW = 112.dp - val capH = 168.dp - val gap = 14.dp // Animations off in the system settings: a still wall. val still = Motion.scale == 0f // The wall moves a few pixels a step. Stepped on frames at most every 15 ms it looks the same as - // stepped on every frame, which on a 120 Hz screen drew it twice as often. + // stepped on every frame, which on a 120 Hz screen drew it twice as often. The phase advances by + // the time since the last step over the period now, so a period that changes (Flathub's wall + // slowing while it sets up) changes the speed without the wall jumping. val drift = remember { mutableFloatStateOf(0f) } - if (!still) LaunchedEffect(driftMs) { - val period = driftMs * 1_000_000.0 * Motion.scale - var start = 0L + val period = androidx.compose.runtime.rememberUpdatedState(driftMs) + if (!still) LaunchedEffect(Unit) { var shown = 0L while (true) withFrameNanos { now -> - if (start == 0L) start = now - if (shown == 0L || now - shown >= DRIFT_STEP_NS) { + if (shown == 0L) shown = now + else if (now - shown >= DRIFT_STEP_NS) { + drift.floatValue = ((drift.floatValue + (now - shown) / (period.value * 1_000_000.0 * Motion.scale)) % 1.0).toFloat() shown = now - drift.floatValue = ((now - start) % period / period).toFloat() } } } @@ -196,14 +215,15 @@ internal fun TiltedWall( val run = (capH + gap) * perRun val cap = with(density) { Size(capW.toPx(), capH.toPx()) } val step = with(density) { (capH + gap).toPx() } - val radius = with(density) { 10.dp.toPx() } + val radius = with(density) { corner.toPx() } val line = with(density) { 1.dp.toPx() } + val barH = with(density) { 12.dp.toPx() } val indexOf = { c: Int, i: Int -> if (count == 0) 0 else (c * 5 + (i % perRun) * 3) % count } val shown = remember(count, columns, perRun) { (0 until columns).flatMap { c -> (0 until perRun).map { indexOf(c, it) } }.distinct() } val looks = HashMap>(shown.size * 2) for (index in shown) key(index) { val look = capsule(index) - looks[index] = look to look.art?.let { rememberCapsuleArt(it, cap, radius) } + looks[index] = look to (look.art?.let { rememberCapsuleArt(it, cap, radius) } ?: look.icon?.let { rememberIcon(it) }) } Row( horizontalArrangement = Arrangement.spacedBy(gap), @@ -224,7 +244,7 @@ internal fun TiltedWall( for (i in 0 until perRun * 2) { val (look, art) = looks[indexOf(c, i)] ?: continue val top = i * step - inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line) } + inset(0f, top, 0f, size.height - top - cap.height) { drawCapsule(look, art, radius, line, barH) } } }, ) @@ -247,9 +267,20 @@ private fun rememberCapsuleArt(model: Any, size: Size, radius: Float): Painter { ) } -private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float) { +/** An app's icon for a wall tile: a bundled drawable, or an image Coil loads. */ +@Composable +private fun rememberIcon(model: Any): Painter = + if (model is Int) androidx.compose.ui.res.painterResource(model) else rememberAsyncImagePainter(model) + +private fun DrawScope.drawCapsule(look: WallCapsule, art: Painter?, radius: Float, line: Float, barH: Float = 0f) { drawRoundRect(look.ground, size = size, cornerRadius = CornerRadius(radius)) - if (art != null) with(art) { draw(size) } + look.bar?.let { drawRect(it, Offset(0f, size.height - barH), Size(size.width, barH)) } + if (art != null && look.icon != null) { + // An app's icon whole at the tile's centre (above a window's bar), not cropped over it. + val side = minOf(size.width, size.height - (if (look.bar != null) barH else 0f)) * 0.5f + val cy = (size.height - (if (look.bar != null) barH else 0f)) / 2f + translate((size.width - side) / 2f, cy - side / 2f) { with(art) { draw(Size(side, side)) } } + } else if (art != null) with(art) { draw(size) } look.edge?.let { drawRoundRect(it, Offset(line / 2, line / 2), Size(size.width - line, size.height - line), CornerRadius(radius - line / 2), style = Stroke(line)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt index 7ab5cbcc0..624176b51 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StepOut.kt @@ -184,6 +184,10 @@ internal fun StepOut( BoxWithConstraints(Modifier.fillMaxSize()) { val hostWidth = constraints.maxWidth.toFloat() + val hostHeight = constraints.maxHeight.toFloat() + // No room under the control (a row near the foot of the screen): it steps out upward + // instead, the same outline mirrored about the control. + val up = measured?.let { pill.bottom + pull + it.height > hostHeight && pill.top - pull - it.height >= 0f } ?: false val room = if (side == StepSide.Left) pill.right else hostWidth - pill.left val maxW = with(density) { maxWidth.toPx() }.coerceAtMost(room).coerceAtLeast(pill.width) LaunchedEffect(open, measured) { @@ -246,6 +250,7 @@ internal fun StepOut( if (!grown) return@Canvas if (side == StepSide.Left) stepPath(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner) else stepPathRight(path, pill, edge.value, bottom.value, pill.bottom + pull, boxCorner, fillet, corner) + if (up) path.transform(Matrix().apply { this[1, 1] = -1f; this[3, 1] = pill.top + pill.bottom }) drawPath(path, lerp(hotFill, restFill, tint.value)) drawPath(path, accent.copy(alpha = lerpF(0.9f, 0.55f, tint.value)), style = Stroke(rim)) } @@ -258,7 +263,7 @@ internal fun StepOut( val x = if (side == StepSide.Left) pill.right - (measured?.width?.toFloat() ?: maxW) else pill.left Column( modifier = Modifier - .offset { IntOffset(x.roundToInt(), (pill.bottom + pull).roundToInt()) } + .offset { IntOffset(x.roundToInt(), (if (up) pill.top - pull - (measured?.height ?: 0) else pill.bottom + pull).roundToInt()) } .widthIn(min = with(density) { pill.width.toDp() }, max = with(density) { maxW.toDp() }) .onSizeChanged { if (measured != it) measured = it } .controllerBack(onDismiss) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Steps.kt b/app/src/main/java/com/droiddeck/launcher/ui/Steps.kt new file mode 100644 index 000000000..d0b9369a3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/Steps.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset + +// The front end's confirms and short lists, each stepping out of the control that asked for it +// (StepOut): a sheet's Uninstall, a Components swap, a remove. The Stores page keeps its own, in +// StoresMotion; everything else asks here and is drawn over the whole screen. + +internal object Steps { + /** The confirm or list stepping out of a control now, and whether it is open. */ + var step by mutableStateOf(null) + private set + var open by mutableStateOf(false) + private set + + fun ask(a: StepAsk) { + step?.onDismiss() + step = a + open = true + } + + /** Folds the current one back into its control. */ + fun fold() { open = false } + + /** Drops it at once: the control it came from has gone. */ + fun drop() { + step?.let { step = null; it.onDismiss() } + open = false + } + + internal fun closed(a: StepAsk) { + if (step === a) step = null + a.onDismiss() + } +} + +/** [Steps] drawn over an element at [origin] (root px) that fills the screen. */ +@Composable +internal fun StepsLayer(origin: Offset) { + val ask = Steps.step ?: return + // Back (B, or the system's) folds what is stepped out, before it reaches the page under it. + androidx.activity.compose.BackHandler(enabled = Steps.open) { Steps.fold() } + key(ask) { + StepOut( + open = Steps.open, pill = ask.anchor.translate(-origin), side = ask.side, accent = ask.accent, + onDismiss = { Steps.fold() }, onClosed = { Steps.closed(ask) }, + pillCorner = ask.pillCorner, maxWidth = ask.maxWidth, items = ask.items, handle = ask.handle, content = ask.content, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt index c35a9beda..410d44e99 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreChips.kt @@ -19,6 +19,13 @@ import com.droiddeck.launcher.stores.Store // Where a game came from, as a small coloured chip: Steam, one of the stores, or an added folder. +/** Flathub's own accent, as each store has one, and the ink that reads on it. */ +internal val FlathubTeal = Color(0xFF3FB8AF) +internal val FlathubInk = Color(0xFF04211F) + +/** The Store rail section's source id, for its colours. */ +internal const val FLATHUB = "flathub" + /** The chip's colours per source: a tinted ground and a readable ink on it, one pair per store. */ internal class SourceColours(val fill: Color, val ink: Color, val dot: Color) @@ -27,6 +34,7 @@ internal fun sourceColours(source: String): SourceColours = when (source) { Store.EPIC.id -> SourceColours(Color(0xFF2A2A2A), Color(0xFFDDDDDD), Color(0xFFBDBDBD)) Store.AMAZON.id -> SourceColours(Color(0xFF3A2A0A), Color(0xFFFFC266), Color(0xFFFF9900)) Library.SOURCE_STEAM -> SourceColours(Color(0xFF16293D), Color(0xFF7CC4FF), Color(0xFF1A9FFF)) + FLATHUB -> SourceColours(Color(0xFF0B2E2B), Color(0xFF9FE3DD), FlathubTeal) else -> SourceColours(Color(0xFF1A1D22), Color(0xFF9AA3AF), Color(0xFF9AA3AF)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt index bf334e2d2..87eaaa92c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StorePage.kt @@ -1,283 +1,429 @@ package com.droiddeck.launcher.ui -import com.droiddeck.launcher.R -import androidx.compose.ui.res.stringResource +import android.os.Build import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Verified +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -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.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.store.FlathubApi import com.droiddeck.launcher.store.StoreState +import kotlinx.coroutines.delay // The Store rail section: Flathub's apps for this device's architecture, installed into the -// Linux runtime with Flatpak, and the ones already installed. +// Linux runtime with Flatpak, and the ones already installed - in the stores' style, in Flathub's +// teal. Until Flatpak is set up it is the stores' sign-in hero: a wall of real app icons, one word, +// one button that becomes its own progress. Then a chip row, shelves of square cards that flood +// open into an app's page, and installs that fly home to the Installed chip. -private val TABS = listOf(R.string.store_tab_discover, R.string.store_tab_search, R.string.store_tab_installed) +private const val MODE_DISCOVER = "discover" +private const val MODE_INSTALLED = "installed" +private const val MODE_UPDATES = "updates" + +/** One app as a card shows it: Flathub's summary or what Flatpak says is installed. */ +private class CardApp(val id: String, val name: String, val icon: Any?, val summary: String? = null, val verified: Boolean = false) + +/** Where the Installed chip is, for an install's dot to land in, and how many have landed. */ +private object FlathubMotion { + var installedChip: Rect? = null + var landed by mutableIntStateOf(0) +} @Composable internal fun StorePage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) { val ctx = LocalContext.current - val narrow = LocalNarrowPane.current - val padH = if (narrow) 16.dp else 22.dp - val padV = if (narrow) 12.dp else 18.dp - var tab by rememberSaveable { mutableStateOf(0) } - var openApp by rememberSaveable { mutableStateOf(null) } - var query by rememberSaveable { mutableStateOf("") } - var category by rememberSaveable { mutableStateOf(null) } LaunchedEffect(s.ready) { StoreState.refresh(ctx) } LaunchedEffect(Unit) { 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 - val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current - val tabFocus = remember { List(TABS.size) { androidx.compose.ui.focus.FocusRequester() } } - var focusMove by remember { mutableStateOf?>(null) } - var lastApp by remember { mutableStateOf(null) } - fun closeApp() { lastApp = openApp; openApp = null; focusMove = "tile" to (focusMove?.second ?: 0) + 1 } - fun switchTab(to: Int) { tab = to; focusMove = "tab" to (focusMove?.second ?: 0) + 1 } - BackHandler(enabled = openApp != null) { closeApp() } - val scroll = rememberScrollState() - LaunchedEffect(openApp, tab) { scroll.scrollTo(0) } - LaunchedEffect(focusMove, openApp) { - if (inputMode.inputMode != androidx.compose.ui.input.InputMode.Keyboard) return@LaunchedEffect - val target = when { - openApp != null -> ff?.primary - focusMove?.first == "tile" -> lastApp?.let { ff?.items?.get("tile:store:$it") } ?: tabFocus[tab] - focusMove?.first == "tab" -> tabFocus[tab] - else -> null - } ?: return@LaunchedEffect - var landed = false - focusWithinFrames({ landed }) { target.also { landed = runCatching { it.requestFocus() }.isSuccess } } - } - Column( - modifier = modifier.verticalScroll(scroll).padding(horizontal = padH, vertical = padV) - .bumpers(onPrevious = { if (openApp == null) switchTab((tab + TABS.size - 1) % TABS.size) }, - onNext = { if (openApp == null) switchTab((tab + 1) % TABS.size) }), - ) { - val id = openApp - if (id != null) { - AppDetail(s, a, id) { closeApp() } - return@Column + FlathubTheme { + // The storefront takes over from the hero once Flatpak is ready: the setup rail rings + // once, then the hero sinks away and the storefront rises. + var front by remember { mutableStateOf(StoreState.ready && s.ready) } + var ring by remember { mutableIntStateOf(0) } + LaunchedEffect(StoreState.ready, s.ready) { + val ready = StoreState.ready && s.ready + if (ready && !front) { + ring++ + delay(Motion.ms(450).toLong()) + front = true + } else if (!ready) front = false } - Rise(0) { - PageHeader(stringResource(R.string.store_title)) { - Chip(stringResource(R.string.store_chip_flathub), ok = false) - if (StoreState.ready) Chip(stringResource(R.string.store_chip_ready), ok = true) - } - } - if (!s.ready) { - Rise(1) { Note(stringResource(R.string.store_needs_runtime)) } - return@Column - } - if (!StoreState.ready) Rise(1) { SetupCard() } - Rise(2) { - TabStrip( - TABS.mapIndexed { i, id -> stringResource(id).let { t -> if (i == 2 && StoreState.installed.isNotEmpty()) stringResource(R.string.store_tab_count, t, StoreState.installed.size) else t } }, - tab, { switchTab(it) }, modifier = Modifier.padding(top = 14.dp, bottom = 4.dp), focusRequesters = tabFocus, - ) - } - BusyBar() - when (tab) { - 0 -> Discover(onOpen = { openApp = it }, onCategory = { category = it; query = ""; tab = 1; StoreState.search("", it) }) - 1 -> Search(query, category, { query = it }, { category = it }, onOpen = { openApp = it }) - else -> Installed(a, onOpen = { openApp = it }) + AnimatedContent( + front, + modifier = modifier, + transitionSpec = { + EnterTransition.None.togetherWith(fadeOut(Motion.tw(320)) + scaleOut(Motion.tw(320), targetScale = 0.95f)) + }, + label = "flathub", + ) { ready -> + if (ready) Storefront(s, a) else FlathubHero(s, ring) } } } -/** Flatpak is not in the runtime yet: one button puts it there. */ +/** + * Flatpak is not set up yet: the stores' sign-in shape in teal. A tilted wall of the popular apps' + * icons (Flathub's public pages need no Flatpak) drifts behind, fading toward the words; one big + * button sets it up, and stretches into its own progress while it does. + */ @Composable -private fun SetupCard() { +private fun FlathubHero(s: FrontEndState, ring: Int) { val ctx = LocalContext.current val colors = MaterialTheme.colorScheme val pal = LocalPalette.current + val narrow = LocalNarrowPane.current + val icons = remember(StoreState.sections.values.toList()) { + StoreState.sections.values.flatten().mapNotNull { it.icon }.distinct().take(48).takeIf { it.size >= 8 }.orEmpty() + } val busy = StoreState.busy == "setup" - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(top = 12.dp).clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(16.dp), - ) { - Text(stringResource(R.string.store_setup_title), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground) - Text( - stringResource(R.string.store_setup_text), - 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) } + BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = if (narrow) 14.dp else 22.dp, vertical = if (narrow) 10.dp else 14.dp)) { + val shape = RoundedCornerShape(20.dp) + Box(Modifier.fillMaxWidth().height(maxHeight.coerceAtLeast(320.dp)).clip(shape).background(colors.background).border(1.dp, pal.line, shape)) { + // Slower while it sets up, as the empty wall drifts. + androidx.compose.animation.Crossfade(icons, animationSpec = Motion.tw(600), label = "flathubWall") { tiles -> + val ground = Brush.linearGradient(listOf(Color(0xFF0B2E2B), Color(0xFF0B2E2B))) + val edge = FlathubTeal.copy(alpha = 0.18f) + TiltedWall(tiles.size, if (tiles.isEmpty() || busy) 75_000 else 50_000, capW = 68.dp, capH = 68.dp, gap = 14.dp, corner = 17.dp) { i -> + WallCapsule(ground, edge = edge, icon = tiles.getOrNull(i)) + } + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to colors.background.copy(alpha = 0.97f), 0.45f to colors.background.copy(alpha = 0.9f), + 0.78f to colors.background.copy(alpha = 0.35f), 1f to colors.background.copy(alpha = 0.15f), + ), + ), + ) + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0.5f to Color.Transparent, 1f to colors.background.copy(alpha = 0.92f)))) + Box( + Modifier.fillMaxSize().drawBehind { + drawRect(Brush.radialGradient(listOf(FlathubTeal.copy(alpha = 0.22f), Color.Transparent), center = Offset(size.width * 0.16f, size.height * 0.9f), radius = size.height * 0.75f)) + }, + ) + BoxWithConstraints(Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(start = if (narrow) 20.dp else 40.dp, bottom = if (narrow) 20.dp else 36.dp, end = 20.dp)) { + val full = maxWidth + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Rise(1) { + Text(stringResource(R.string.flathub_eyebrow), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp, color = FlathubTeal) + } + Rise(2) { + Text( + stringResource(R.string.flathub_name), fontSize = if (narrow) 30.sp else 42.sp, lineHeight = if (narrow) 34.sp else 46.sp, + fontWeight = FontWeight.Black, color = colors.onBackground, + ) + } + Rise(3) { Text(stringResource(R.string.flathub_line), fontSize = 15.sp, color = colors.onSurfaceVariant) } + Spacer(Modifier.height(8.dp)) + Rise(4) { SetupButton(s, busy, full, ring) { StoreState.setup(ctx) } } + } + } } } } -/** The running install, update or setup, whichever page it was started from. */ +/** + * Set up Flathub, and its own progress: while it sets up the button springs out to the full width + * and fills in teal from the left with how far it is, the stage and the figure inside it. Without + * a runtime it says what is missing instead. + */ @Composable -private fun BusyBar() { +private fun SetupButton(s: FrontEndState, busy: Boolean, full: Dp, ring: Int, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme - val busy = StoreState.busy ?: return - val name = StoreState.installed.firstOrNull { it.id == busy }?.name ?: StoreState.details[busy]?.name - ?: when (busy) { "setup" -> stringResource(R.string.store_flatpak); "update-all" -> stringResource(R.string.store_updates); else -> busy.substringAfterLast('.') } - Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 4.dp)) { - val stage = StoreState.stage - Text( - if (stage != null && StoreState.percent >= 0) stringResource(R.string.store_busy_percent, name, stage, StoreState.percent) else stringResource(R.string.store_busy, name, stage ?: stringResource(R.string.store_starting)), - fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(bottom = 6.dp), - ) - if (StoreState.percent >= 0) LinearProgressIndicator(progress = { StoreState.percent / 100f }, modifier = Modifier.fillMaxWidth().height(4.dp)) - else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)) - } -} - -@Composable -private fun Discover(onOpen: (String) -> Unit, onCategory: (String) -> Unit) { - 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) } } + val density = LocalDensity.current + val ff = LocalFrontFocus.current + val inputMode = LocalInputModeManager.current + var restW by remember { mutableStateOf(0.dp) } + val grow by animateFloatAsState(if (busy) 1f else 0f, Motion.sp(0.7f, 380f), label = "setupGrow") + val fill by animateFloatAsState((StoreState.percent.coerceAtLeast(0) / 100f), Motion.tw(300), label = "setupFill") + if (!busy && grow < 0.01f) { + Box(Modifier.onSizeChanged { restW = with(density) { it.width.toDp() } }) { + PrimaryButton( + if (s.ready) stringResource(R.string.flathub_setup) else stringResource(R.string.store_needs_runtime), + enabled = s.ready && StoreState.busy == null, main = true, large = true, icon = Icons.Filled.Download, onClick = onClick, + ) } - } - if (StoreState.sectionsFailed && StoreState.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) } } return } - StoreState.SECTIONS.forEachIndexed { i, (key, title) -> - val apps = StoreState.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) } + // A pad keeps its place on the rail that took the button's. + if (ff != null) LaunchedEffect(Unit) { if (inputMode.inputMode == InputMode.Keyboard) focusSoon { ff.primary } } + val width = androidx.compose.ui.unit.lerp(restW.coerceAtLeast(120.dp), full, grow) + val shape = RoundedCornerShape(12.dp) + val shimmer = rememberInfiniteTransition(label = "setupShimmer") + val sweep by shimmer.animateFloat(-0.3f, 1.3f, infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), label = "sweep") + val stage = StoreState.stage ?: stringResource(R.string.store_starting) + val label = if (StoreState.percent >= 0) "$stage · ${StoreState.percent}%" else stage + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier.width(width).heightIn(min = 56.dp) + .then(if (ff != null) Modifier.focusRequester(ff.primary) else Modifier) + .pulseRing(ring, FlathubTeal) + .clip(shape).background(colors.surfaceVariant).border(1.dp, FlathubTeal.copy(alpha = 0.5f), shape) + .drawBehind { + if (StoreState.percent >= 0) drawRect(FlathubTeal, size = size.copy(width = size.width * fill)) + else if (Motion.scale != 0f) { + val w = size.width * 0.3f + drawRect(Brush.horizontalGradient(listOf(Color.Transparent, FlathubTeal.copy(alpha = 0.55f), Color.Transparent), startX = size.width * sweep - w, endX = size.width * sweep + w)) + } + } + .focusable() + .semantics { contentDescription = label } + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + Text(label, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = if (StoreState.percent >= 50) FlathubInk else colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) } } +// ---- storefront ------------------------------------------------------------------------------------ + @Composable -private fun Search(query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String?) -> Unit, onOpen: (String) -> Unit) { - 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) +private fun Storefront(s: FrontEndState, a: FrontEndActions) { + val ctx = LocalContext.current + val narrow = LocalNarrowPane.current + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val padH = if (narrow) 14.dp else 22.dp + var mode by rememberSaveable { mutableStateOf(MODE_DISCOVER) } + var query by rememberSaveable { mutableStateOf("") } + var category by rememberSaveable { mutableStateOf(null) } + var openApp by rememberSaveable { mutableStateOf(null) } + val updates = StoreState.updates + if (mode == MODE_UPDATES && updates.isEmpty()) mode = MODE_INSTALLED + // Once a session is plenty: the Updates chip shows itself when there is something to update. + LaunchedEffect(StoreState.ready) { if (StoreState.ready && !StoreState.updatesChecked && !StoreState.checkingUpdates) StoreState.checkUpdates(ctx) } + // A search runs a beat after typing stops, or as a category is picked. + LaunchedEffect(query, category) { + if (query.isBlank() && category == null) { StoreState.clearSearch(); return@LaunchedEffect } + delay(350) StoreState.search(query.trim(), category) } - Rise(3) { - Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { - AdbTextField( - value = query, onValueChange = onQuery, label = stringResource(R.string.store_search_label), keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done, placeholder = stringResource(R.string.store_search_placeholder), modifier = Modifier.weight(1f), onDone = run, - ) - PrimaryButton(stringResource(R.string.store_search), main = true, onClick = run) + val ff = LocalFrontFocus.current + val inputMode = LocalInputModeManager.current + // Cards, where they are, for the page that opens from one. + val cards = remember { HashMap() } + var viewportAt by remember { mutableStateOf(Offset.Zero) } + var detail by remember { mutableStateOf(null) } + var leaving by remember { mutableStateOf(false) } + var detailFrom by remember { mutableStateOf(null) } + var lastApp by remember { mutableStateOf(null) } + LaunchedEffect(openApp) { + val id = openApp + if (id != null) { + detailFrom = cards[id]?.let { Origin(it.translate(-viewportAt), 16f * density.density) } + leaving = false + detail = id + if (inputMode.inputMode == InputMode.Keyboard && ff != null) { delay(Motion.ms(300).toLong()); focusSoon { ff.primary } } + } else if (detail != null) { + lastApp = detail + if (detailFrom != null) { leaving = true; delay(Motion.ms(PAGE_RETURN_MS).toLong()) } + detail = null; leaving = false; detailFrom = null } } - 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) } - FlathubApi.categories.forEach { c -> - PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id); StoreState.search(query.trim(), c.id) } + // Back on the grid: focus goes to the card the page came from. + LaunchedEffect(detail) { + if (detail != null || inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect + val id = lastApp ?: return@LaunchedEffect + ff.items["tile:store:$id"]?.let { req -> focusSoon { req } } + } + BackHandler(enabled = openApp != null) { openApp = null } + // An app's card on Desktop asked for its page. + LaunchedEffect(StoreState.openRequest) { StoreState.openRequest?.let { openApp = it; StoreState.openRequest = null } } + val covering = detail != null && !leaving + val sink by animateFloatAsState(if (covering) 0.95f else 1f, if (covering) Motion.tw(480) else Motion.tw(460, 40), label = "flathubSink") + val fade by animateFloatAsState(if (covering) 0f else 1f, if (covering) Motion.tw(320, 80) else Motion.tw(300, 100), label = "flathubFade") + val modes = buildList { add(MODE_DISCOVER); add(MODE_INSTALLED); if (updates.isNotEmpty()) add(MODE_UPDATES) } + // LB / RB with a pad: focus goes to the new mode's chip; the cards it was on leave with the old mode. + val seenMode = remember { arrayOf(mode) } + LaunchedEffect(mode) { + if (seenMode[0] == mode) return@LaunchedEffect + seenMode[0] = mode + if (inputMode.inputMode != InputMode.Keyboard || ff == null) return@LaunchedEffect + ff.items["flathub:$mode"]?.let { req -> focusSoon { req } } + } + + Column( + Modifier.fillMaxSize().padding(horizontal = padH, vertical = if (narrow) 10.dp else 14.dp) + .bumpers( + onPrevious = { if (openApp == null) mode = modes[(modes.indexOf(mode) + modes.size - 1) % modes.size] }, + onNext = { if (openApp == null) mode = modes[(modes.indexOf(mode) + 1) % modes.size] }, + ), + ) { + Rise(0) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + // A page of Desktop's: the way back to it, for a finger (B does the same). + val show = LocalShowSection.current + BackLink(stringResource(R.string.rail_desktop), compact = true) { show("desktop") } + Box(Modifier.weight(1f)) { FlathubChips(mode, modes, StoreState.installed.size, updates.size) { mode = it } } } } - } - val results = StoreState.searchResults - when { - StoreState.searching -> Rise(5) { SectionTitle(stringResource(R.string.store_searching), null) } - results == null -> Rise(5) { Note(stringResource(R.string.store_search_hint)) } - results.isEmpty() -> Rise(5) { Note(stringResource(R.string.store_no_results)) } - else -> { - Rise(5) { SectionTitle(stringResource(R.string.store_results), results.size.toString()) } - Rise(6) { AppGrid(results, first = true, onOpen = onOpen) } + Spacer(Modifier.height(8.dp)) + Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().onGloballyPositioned { viewportAt = it.positionInRoot() }) { + Column( + Modifier.fillMaxSize().graphicsLayer { scaleX = sink; scaleY = sink; alpha = fade }.verticalScroll(rememberScrollState()).padding(bottom = 16.dp), + ) { + val open: (String) -> Unit = { openApp = it } + when (mode) { + MODE_DISCOVER -> Discover(query, category, { query = it }, { category = if (category == it) null else it }, cards, open) + MODE_INSTALLED -> InstalledMode(cards, open) + else -> UpdatesMode(cards, open) + } + } + detail?.let { id -> + val from = detailFrom + val icon = StoreState.details[id]?.icon ?: StoreState.sections.values.flatten().firstOrNull { it.id == id }?.icon ?: FlathubApi.iconUrl(id) + val body: @Composable () -> Unit = { + Column( + Modifier.fillMaxSize().background(colors.background).pointerInput(Unit) { detectTapGestures { } } + .verticalScroll(rememberScrollState()).padding(bottom = 16.dp).focusGroup(), + ) { AppDetail(s, a, id) { openApp = null } } + } + if (from == null) body() + else { + // The card's own icon, cropped over the tile as it grows: the page arrives in the app's colour. + val art = coil.compose.rememberAsyncImagePainter( + remember(icon) { coil.request.ImageRequest.Builder(ctx).data(icon).size(256, 256).build() }, + ) + PageFlood(from, leaving, art) { body() } + } + } } } } +/** + * Discover, Installed (with its count) and Updates (when there are any): one selection glides under + * them in teal; the Installed count pops as an install's dot lands in it. + */ @Composable -private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { - val ctx = LocalContext.current - val colors = MaterialTheme.colorScheme +private fun FlathubChips(mode: String, modes: List, installed: Int, updates: Int, onPick: (String) -> Unit) { val pal = LocalPalette.current - val apps = StoreState.installed - 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 (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)) - } else if (StoreState.updatesChecked && !StoreState.checkingUpdates) ActionChip(stringResource(R.string.store_all_current), ok = true) - } - } - if (apps.isEmpty()) { - Rise(4) { Box(Modifier.padding(top = 12.dp)) { Note(if (StoreState.ready) stringResource(R.string.store_no_apps) else stringResource(R.string.store_setup_first_note)) } } - return - } - Rise(4) { SectionTitle(stringResource(R.string.store_tab_installed), apps.size.toString()) } - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - apps.forEachIndexed { i, app -> - key(app.id) { - Rise(5 + i.coerceAtMost(6)) { + val colors = MaterialTheme.colorScheme + val bounds = remember { mutableStateMapOf() } + val tint = rememberUpdatedState(pal.signal) + Box(Modifier.fillMaxWidth()) { + ChipGlide(modes.indexOf(mode), bounds[mode], tint) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + for (m in modes) { + key(m) { + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val shape = RoundedCornerShape(99.dp) + val on = m == mode Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14).padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + modifier = Modifier.weight(1f).paneItem("flathub:$m") + .onGloballyPositioned { + bounds[m] = it.boundsInParent() + if (m == MODE_INSTALLED) FlathubMotion.installedChip = it.boundsInRoot() + } + .heightIn(min = 44.dp).clip(shape) + .background(if (on) Color.Transparent else colors.surface) + .glideBorder(hot, shape, pal.signal, if (on) Color.Transparent else pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Tab) { onPick(m) } + .controllerConfirm { onPick(m) } + .padding(horizontal = 12.dp, vertical = 8.dp), ) { - AppIcon(app.icon ?: StoreState.details[app.id]?.icon ?: FlathubApi.iconUrl(app.id), app.name, 44) - Column(modifier = Modifier.weight(1f)) { - Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text( - (app.summary ?: app.id).let { if (app.id in StoreState.updates) stringResource(R.string.store_update_available_prefix, it) else it }, - fontSize = 13.sp, color = if (app.id in StoreState.updates) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - } - 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) } - SecondaryButton(stringResource(R.string.store_details), compact = true) { onOpen(app.id) } + Text( + stringResource(when (m) { MODE_DISCOVER -> R.string.store_tab_discover; MODE_INSTALLED -> R.string.store_tab_installed; else -> R.string.store_updates }), + fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (on || hot) colors.onBackground else colors.onSurfaceVariant, maxLines = 1, + ) + val count = when (m) { MODE_INSTALLED -> installed; MODE_UPDATES -> updates; else -> -1 } + if (count >= 0) Text( + count.toString(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = pal.onSignal, maxLines = 1, + modifier = Modifier.then(if (m == MODE_INSTALLED) Modifier.landingPop(FlathubMotion.landed) else Modifier) + .clip(RoundedCornerShape(8.dp)).background(pal.signal).padding(horizontal = 6.dp, vertical = 1.dp), + ) } } } @@ -285,8 +431,202 @@ private fun Installed(a: FrontEndActions, onOpen: (String) -> Unit) { } } -private fun s_busyFor(id: String) = StoreState.busy == id +/** + * The card width for a width: room for a name on two lines and a two-line summary under the art, + * so a long name wraps instead of being cut to a few letters. + */ +private fun cardSize(width: Dp): Dp = when { + width >= 700.dp -> 164.dp + width >= 480.dp -> 148.dp + else -> 132.dp +} +@Composable +private fun Discover( + query: String, category: String?, onQuery: (String) -> Unit, onCategory: (String) -> Unit, + cards: HashMap, onOpen: (String) -> Unit, +) { + Rise(1) { + ModeHeader( + stringResource(R.string.store_tab_discover), query, onQuery, stringResource(R.string.store_search_placeholder), + ) { + // The categories as small pills, at the right of the header. + val pills = rememberScrollState() + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.widthIn(max = 420.dp).edgeFade(pills).horizontalScroll(pills), + ) { + FlathubApi.categories.forEach { c -> PillButton(stringResource(c.label), selected = category == c.id) { onCategory(c.id) } } + } + } + } + val results = StoreState.searchResults + if (query.isNotBlank() || category != null) { + when { + StoreState.searching && results == null -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_searching)) } } + results.isNullOrEmpty() -> Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_results)) } } + else -> Rise(2) { CardGrid(results.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, cards, onOpen) } + } + return + } + if (StoreState.sectionsFailed && StoreState.sections.values.all { it.isEmpty() }) { + Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_unreachable)) } } + Actions { SecondaryButton(stringResource(R.string.store_try_again)) { StoreState.loadSections(force = true) } } + return + } + StoreState.SECTIONS.forEachIndexed { i, (key, title) -> + val apps = StoreState.sections[key] + Rise(2 + i) { Shelf(stringResource(title), apps?.map { CardApp(it.id, it.name, it.icon, it.summary, it.verified) }, first = i == 0, cards, onOpen) } + } +} + +@Composable +private fun InstalledMode(cards: HashMap, onOpen: (String) -> Unit) { + val ctx = LocalContext.current + val apps = StoreState.installed + Rise(1) { + ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_tab_installed), apps.size), "", {}, "") { + SmallLink(if (StoreState.checkingUpdates) stringResource(R.string.common_checking) else stringResource(R.string.store_check_updates), enabled = !StoreState.checkingUpdates && StoreState.busy == null) { + StoreState.checkUpdates(ctx) + } + } + } + if (apps.isEmpty()) { Rise(2) { Box(Modifier.padding(top = 12.dp)) { Note(stringResource(R.string.store_no_apps)) } }; return } + Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) } +} + +@Composable +private fun UpdatesMode(cards: HashMap, onOpen: (String) -> Unit) { + val ctx = LocalContext.current + val apps = StoreState.installed.filter { it.id in StoreState.updates } + Rise(1) { + ModeHeader(stringResource(R.string.store_tab_count, stringResource(R.string.store_updates), apps.size), "", {}, "") { + PrimaryButton(stringResource(R.string.store_update_all, apps.size), compact = true, enabled = StoreState.busy == null) { + StoreState.update(ctx, null, ctx.getString(R.string.store_updates)) + } + } + } + Rise(2) { CardGrid(apps.map { CardApp(it.id, it.name, it.icon ?: StoreState.details[it.id]?.icon ?: FlathubApi.iconUrl(it.id), it.summary) }, cards, onOpen) } +} + +/** A horizontal row of cards under a title, the stores' shelf; nothing while it is empty. */ +@Composable +private fun Shelf(title: String, apps: List?, first: Boolean, cards: HashMap, onOpen: (String) -> Unit) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth()) { + Text(title, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 14.dp, bottom = 6.dp)) + if (apps == null) Text(stringResource(R.string.store_section_loading), fontSize = 13.sp, color = colors.onSurfaceVariant) + else if (apps.isNotEmpty()) BoxWithConstraints(Modifier.fillMaxWidth()) { + val size = cardSize(maxWidth) + val shelf = rememberScrollState() + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shelf).horizontalScroll(shelf).padding(vertical = 2.dp)) { + apps.take(18).forEachIndexed { i, app -> key(app.id) { AppCard(app, size, first && i == 0, cards) { onOpen(app.id) } } } + } + } + } +} + +/** The Installed and Updates modes, and search: a grid of the same cards. */ +@Composable +private fun CardGrid(apps: List, cards: HashMap, onOpen: (String) -> Unit) { + BoxWithConstraints(Modifier.fillMaxWidth().padding(top = 10.dp)) { + val size = cardSize(maxWidth) + val columns = ((maxWidth + 10.dp) / (size + 10.dp)).toInt().coerceAtLeast(1) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + apps.chunked(columns).forEachIndexed { r, row -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEachIndexed { i, app -> key(app.id) { AppCard(app, size, r == 0 && i == 0, cards) { onOpen(app.id) } } } + } + } + } + } +} + +/** + * One app: its icon over a blurred, darkened copy of itself (darkened only, before Android 12), + * with one status on the art - Get, ✓, or how far an install is - and under it the name, on two + * lines if it needs them, and what the app is. + */ +@Composable +private fun AppCard(app: CardApp, size: Dp, first: Boolean, cards: HashMap, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = rememberHot(src) + val pressed by src.collectIsPressedAsState() + val scale by animateFloatAsState(if (pressed) 0.96f else if (hot) 1.03f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "cardScale") + val shape = RoundedCornerShape(16.dp) + val installed = StoreState.installed.any { it.id == app.id } + val update = app.id in StoreState.updates + val busy = StoreState.busy == app.id + val artH = size * 0.62f + Column( + Modifier.width(size).paneItem("tile:store:${app.id}").then(if (first) Modifier.firstTile() else Modifier) + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(shape).background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) + .glideBorder(hot, shape, pal.signal, pal.line) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) + .controllerConfirm(onClick = onClick) + .semantics { contentDescription = app.name }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().height(artH).onGloballyPositioned { cards[app.id] = it.boundsInRoot() } + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)).background(Color(0xFF0B2E2B)), + ) { + if (app.icon != null) { + // The icon's own colour, blurred and darkened, as the card's ground. + AsyncImage( + model = app.icon, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.8f; scaleY = 1.8f; alpha = 0.55f } + .then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(18.dp) else Modifier), + ) + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f))) + AsyncImage(model = app.icon, contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.size(artH * 0.62f)) + } else Icon(Icons.Outlined.Apps, null, tint = colors.onSurfaceVariant, modifier = Modifier.size(artH * 0.5f)) + // One status, on the art: what A on the card leads to. + Box(Modifier.align(Alignment.TopEnd).padding(6.dp)) { + when { + busy -> Box(Modifier.clip(RoundedCornerShape(99.dp)).background(Color.Black.copy(alpha = 0.55f)).padding(4.dp)) { + if (StoreState.percent >= 0) CircularProgressIndicator(progress = { StoreState.percent / 100f }, strokeWidth = 2.dp, color = pal.signal, trackColor = pal.signal.copy(alpha = 0.25f), modifier = Modifier.size(16.dp)) + else CircularProgressIndicator(strokeWidth = 2.dp, color = pal.signal, modifier = Modifier.size(16.dp)) + } + else -> Text( + when { + update -> stringResource(R.string.common_update) + installed -> "✓" + else -> stringResource(R.string.comp_get) + }, + fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1, + color = if (installed && !update) FlathubInk else pal.onSignal, + modifier = Modifier.clip(RoundedCornerShape(99.dp)).background(if (installed && !update) pal.good else pal.signal) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) + } + } + } + Column(Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 8.dp, bottom = 10.dp)) { + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + app.name, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, lineHeight = 17.sp, color = colors.onBackground, + maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), + ) + if (app.verified) Icon(Icons.Outlined.Verified, stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.padding(top = 2.dp).size(13.dp)) + } + // Two lines kept for the summary even when it is short, so cards in a row line up. + Text( + app.summary.orEmpty(), fontSize = 12.sp, lineHeight = 15.sp, color = colors.onSurfaceVariant, + minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp), + ) + } + } +} + +/** + * An app's page, flooded out of its card: its icon, name and what it is, one action (Install or + * Open, with Update beside it when there is one) and Website; then its screenshots and details. + * Install flies a dot to the Installed chip; Remove asks by stepping out of its button, in red. + */ @Composable private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: () -> Unit) { val ctx = LocalContext.current @@ -296,44 +636,53 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: val d = StoreState.details[id] val local = StoreState.installed.firstOrNull { it.id == id } val name = d?.name ?: local?.name ?: id - Rise(0) { BackLink(stringResource(R.string.store_title), onClick = onBack) } + val busyHere = StoreState.busy == id + val installed = local != null + Rise(0) { BackLink(stringResource(R.string.flathub_name), onClick = onBack) } Rise(1) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(top = 12.dp, bottom = 8.dp)) { AppIcon(d?.icon ?: local?.icon ?: FlathubApi.iconUrl(id), name, 72) Column(modifier = Modifier.weight(1f)) { Text(name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis) - d?.developer?.let { Text(it, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + val meta = listOfNotNull( + d?.developer, d?.version?.let { "v$it" }, + d?.downloadSize?.takeIf { it > 0 }?.let { stringResource(R.string.store_download_size, FileUtils.sizeToString(it)) }, + ).joinToString(" · ") + if (meta.isNotEmpty()) Text(meta, fontSize = 14.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) (d?.summary ?: local?.summary)?.let { Text(it, fontSize = 14.sp, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) } } } } - val busyHere = StoreState.busy == id - val installed = local != null - // Remove asks twice: one stray press of A should not cost a download. The second press has - // to come within a few seconds; the app's own data in ~/.var/app stays either way. - var confirmRemove by remember(id) { mutableStateOf(false) } - LaunchedEffect(confirmRemove) { if (confirmRemove) { kotlinx.coroutines.delay(4000); confirmRemove = false } } Rise(2) { Actions { if (installed) { - PrimaryButton(stringResource(R.string.store_open), enabled = !busyHere, main = true) { a.onFlatpakApp(id, name) } + PrimaryButton(if (busyHere) stringResource(R.string.common_working) else 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) } - 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 - } } 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 (!StoreState.ready) ActionChip(stringResource(R.string.store_setup_first), ok = false) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + PrimaryButton( + if (busyHere) (if (StoreState.percent >= 0) "${StoreState.stage ?: stringResource(R.string.store_installing)} · ${StoreState.percent}%" else stringResource(R.string.store_installing)) + else stringResource(R.string.store_install), + main = true, + enabled = StoreState.busy == null && s.ready && (d == null || d.arches.isEmpty() || "aarch64" in d.arches), + ) { + StoreState.install(ctx, id, name) + // The button squashes into a dot that flies to the Installed chip, whose count pops. + val from = at + if (from != null) Flights.fly(Flight(from, to = { FlathubMotion.installedChip?.center }, onLand = { FlathubMotion.landed++ })) + else FlathubMotion.landed++ + } + } } - d?.version?.let { ActionChip("v$it", ok = false) } - if (d != null && d.downloadSize > 0) ActionChip(stringResource(R.string.store_download_size, FileUtils.sizeToString(d.downloadSize)), ok = false) - if (installed) ActionChip(stringResource(R.string.store_installed_chip), ok = true) + d?.homepage?.let { url -> + SecondaryButton(stringResource(R.string.store_website)) { + runCatching { ctx.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))) } + } + } + if (installed) RemoveButton(id, name, enabled = StoreState.busy == null) } } - if (busyHere) BusyBar() if (d == null) { Rise(3) { Box(Modifier.padding(top = 14.dp)) { @@ -346,7 +695,8 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: if (d.screenshots.isNotEmpty()) { Rise(3) { SectionTitle(stringResource(R.string.store_screenshots), null) } Rise(3) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + val shots = rememberScrollState() + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().edgeFade(shots).horizontalScroll(shots)) { d.screenshots.take(6).forEach { shot -> AsyncImage( model = shot.thumb, contentDescription = null, contentScale = ContentScale.Crop, @@ -379,52 +729,45 @@ private fun AppDetail(s: FrontEndState, a: FrontEndActions, id: String, onBack: } } -/** Apps as wide tiles: icon, name, summary - three across, or a list on a narrow page. */ +/** Remove, which asks first: are you sure steps out of it in red, Keep first, so a stray A costs nothing. */ @Composable -private fun AppGrid(apps: List, first: Boolean, onOpen: (String) -> Unit) { - val columns = if (LocalNarrowPane.current) 1 else 3 - Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 8.dp)) { - apps.chunked(columns).forEachIndexed { r, row -> - Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - row.forEachIndexed { i, app -> - key(app.id) { - AppTile(app, Modifier.weight(1f).fillMaxHeight(), isFirst = first && r == 0 && i == 0) { onOpen(app.id) } - } +private fun RemoveButton(id: String, name: String, enabled: Boolean) { + val ctx = LocalContext.current + val colors = MaterialTheme.colorScheme + val label = stringResource(R.string.store_remove) + var at by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) { + SecondaryButton(label, enabled = enabled) { + val from = at ?: return@SecondaryButton + Steps.ask(StepAsk( + anchor = from, side = StepSide.Right, accent = colors.error, pillCorner = 12.dp, items = 3, + handle = { Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.error, maxLines = 1) }, + ) { + StepTitle(ctx.getString(R.string.flathub_remove_title, name)) + Text(ctx.getString(R.string.flathub_remove_note), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.stepItem(1).padding(start = 4.dp, bottom = 12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.stepItem(2)) { + StepChoice(ctx.getString(R.string.flathub_keep), enabled = open, modifier = Modifier.focusRequester(first)) { Steps.fold() } + StepChoice(label, danger = true, enabled = open) { Steps.fold(); StoreState.uninstall(ctx, id, name) } } - repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } - } + }) } } } +/** A small text action at the right of a header. */ @Composable -private fun AppTile(app: FlathubApi.AppSummary, modifier: Modifier, isFirst: Boolean, onClick: () -> Unit) { +private fun SmallLink(text: String, enabled: Boolean = true, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } - val hot = rememberHot(src) - val pressed by src.collectIsPressedAsState() - val scale by animateFloatAsState(if (pressed) 0.97f else 1f, Motion.sp(0.5f, Spring.StiffnessMedium), label = "appScale") - val installed = StoreState.installed.any { it.id == app.id } - Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier.paneItem("tile:store:${app.id}").then(if (isFirst) Modifier.firstTile() else Modifier) - .graphicsLayer { scaleX = scale; scaleY = scale } - .clip(Shape14) - .background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface) - .glideBorder(hot, Shape14, pal.signal, pal.line) - .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - ) { - AppIcon(app.icon, app.name, 44) - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text(app.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) - if (app.verified) Icon(Icons.Outlined.Verified, contentDescription = stringResource(R.string.store_verified), tint = pal.signal, modifier = Modifier.size(14.dp)) - } - Text(if (installed) stringResource(R.string.store_tab_installed) else app.summary, fontSize = 13.sp, color = if (installed) pal.good else colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - } + val hot = rememberHot(src) && enabled + Text( + text, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + color = if (!enabled) colors.onSurfaceVariant.copy(alpha = 0.5f) else if (hot) colors.onBackground else colors.onSurfaceVariant, + modifier = Modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(8.dp)).glideBorder(hot, RoundedCornerShape(8.dp), pal.signal) + .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.Button, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick).padding(horizontal = 8.dp, vertical = 12.dp), + ) } /** An app's icon from Flathub (a URL) or from its installed files; a placeholder until it loads. */ @@ -446,13 +789,13 @@ private fun PillButton(label: String, selected: Boolean, onClick: () -> Unit) { val hot = rememberHot(src) val shape = RoundedCornerShape(99.dp) Text( - label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, + label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, color = if (selected) pal.onSignal else if (hot) colors.onBackground else colors.onSurfaceVariant, - modifier = Modifier.paneItem("pill:$label").clip(shape) + modifier = Modifier.paneItem("pill:$label").heightIn(min = 44.dp).padding(vertical = 6.dp).clip(shape) .background(if (selected) pal.signal else if (hot) pal.signal.copy(alpha = 0.16f) else colors.surfaceVariant) .glideBorder(hot, shape, pal.signal, pal.line) .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = onClick) .controllerConfirm(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 9.dp), + .padding(horizontal = 12.dp, vertical = 7.dp), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt index d55b82805..8f902df1c 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt @@ -24,6 +24,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.material.icons.filled.Settings +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer @@ -490,31 +493,36 @@ internal fun DownloadControls(s: FrontEndState, a: FrontEndActions) { } /** - * The chip row's cog: the section's own settings in a small card - which tab a store opens on - * and the speed tier. Setup › Stores repeats them under its gate. Every install is added to Steam; - * that is not a choice. + * The chip row's cog: the section's own settings stepped out of it - which tab a store opens on, + * mature titles, the speed tier and how many at once, and the download engine. Their only home; + * Setup no longer repeats them. Every install is added to Steam; that is not a choice. [s] is + * read live, so a change shows at once in what is stepped out. */ -@Composable -internal fun StoresSettingsDialog(s: FrontEndState, a: FrontEndActions, onDismiss: () -> Unit) { - val shown = rememberShown(onDismiss) - val close = { shown.targetState = false } - AppDialog(shown, close, "storesSettings", wide = false, maxWidth = 440.dp) { - DialogHeader(stringResource(R.string.stores_settings_eyebrow), stringResource(R.string.stores_title)) - Rise(1) { - Column { - SettingsRow(stringResource(R.string.setup_stores_open_on), null) { - SegmentedTabs( - listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), - s.storesOpenTab, - ) { a.onStoresOpenTab(it) } - } - SettingsRow(stringResource(R.string.stores_show_mature), null) { - ToggleSwitch(s.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) } - } - // The queue's two knobs as one compact row, the same control the Downloads page has. - Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(s, a) } +internal fun storesSettingsStep(at: Rect, s: androidx.compose.runtime.State, a: FrontEndActions, accent: androidx.compose.ui.graphics.Color): StepAsk = + StepAsk( + anchor = at, side = StepSide.Left, accent = accent, items = 3, maxWidth = 440.dp, + handle = { androidx.compose.material3.Icon(androidx.compose.material.icons.Icons.Filled.Settings, null, tint = accent, modifier = Modifier.size(20.dp)) }, + ) { + StepTitle(stringResource(R.string.stores_settings_eyebrow)) + Column( + Modifier.stepItem(1).fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val st = s.value + SettingsRow(stringResource(R.string.setup_stores_open_on), null) { + SegmentedTabs( + listOf(SessionPrefs.STORES_OPEN_LIBRARY to stringResource(R.string.stores_tab_library), SessionPrefs.STORES_OPEN_STORE to stringResource(R.string.stores_tab_store)), + st.storesOpenTab, + ) { a.onStoresOpenTab(it) } } + SettingsRow(stringResource(R.string.stores_show_mature), null) { + ToggleSwitch(st.storesShowMature, label = stringResource(R.string.stores_show_mature)) { a.onStoresShowMature(it) } + } + // The queue's two knobs as one compact row, the same control the Downloads page has. + Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { DownloadControls(st, a) } + StoresEngineRow() + } + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth().stepItem(2).padding(top = 8.dp)) { + StepChoice(stringResource(R.string.common_ok), enabled = open, modifier = Modifier.focusRequester(first)) { StoresMotion.fold() } } - Rise(2) { Actions { SecondaryButton(stringResource(R.string.common_ok), onClick = close) } } } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt index 3072b2611..9f216ebda 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresMotion.kt @@ -83,7 +83,8 @@ internal object StoresMotion { fun fly(from: Rect) { if (Motion.scale == 0f || downloadsChip == null) { landed++; return } - flights += Flight(from, SystemClock.uptimeMillis()) + // The count sits at the chip's right end. + flights += Flight(from, to = { downloadsChip?.let { Offset(it.right - 22.dp.toPx(), it.center.y) } }, onLand = { landed++ }) } /** The confirm or list stepping out of a control on the Stores page now, and whether it is open. */ @@ -140,8 +141,6 @@ internal object StoresMotion { /** A game card at [bounds] (root px) with corner [corner] (px) and its art, for the page it opens to flood out of. */ internal class CardMark(val key: String, val bounds: Rect, val corner: Float, val art: String?) -internal class Flight(val from: Rect, val id: Long) - /** * A confirm or short list for [StepOut] on the Stores page, asked by a control that sits at [anchor] * (root px). [content] gets a close that folds it; [onDismiss] runs once it has folded, however. @@ -153,6 +152,7 @@ internal class StepAsk( val pillCorner: androidx.compose.ui.unit.Dp? = null, val items: Int = 4, val onDismiss: () -> Unit = {}, + val maxWidth: androidx.compose.ui.unit.Dp = 300.dp, val handle: @Composable () -> Unit, val content: @Composable StepOutScope.() -> Unit, ) @@ -167,63 +167,21 @@ internal class SignInFlood(val store: Store, val from: Rect, val start: Color, v /** The dots flying to the Downloads chip, drawn over the page whose root offset is [origin]. */ @Composable internal fun FlightLayer(origin: Offset) { - for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) } + for (f in StoresMotion.flights.toList()) key(f.id) { FlightDot(f, origin) { StoresMotion.flights.remove(f) } } } /** - * One install's dot: the button squashes into a 14dp dot (width first, a little bloop), which then - * flies on an arc to the Downloads chip, stretched along its path like the focus ring's drop. + * Pops (1.3 to 1, with one ring) each time [trigger] changes: by default each time a download's + * dot lands, for the count on the chip and the rail; any other landing passes its own count. */ -@Composable -private fun FlightDot(f: Flight, origin: Offset) { - val pal = LocalPalette.current - val t = remember { Animatable(0f) } - LaunchedEffect(Unit) { - t.animateTo(1f, Motion.tw(150, easing = FastOutSlowInEasing)) - t.animateTo(2f, Motion.tw(300, easing = FastOutSlowInEasing)) - StoresMotion.landed++ - StoresMotion.flights.remove(f) - } - Canvas(Modifier.fillMaxSize()) { - val r = 7.dp.toPx() - val from = f.from.translate(-origin) - val chip = (StoresMotion.downloadsChip ?: return@Canvas).translate(-origin) - val a = from.center - // The count sits at the chip's right end. - val b = Offset(chip.right - 22.dp.toPx(), chip.center.y) - val v = t.value - if (v < 1f) { - // Squash: the width goes first, then the height, into the dot; a bloop at the end. - val w = lerp(from.width, 2 * r, (v * 1.4f).coerceAtMost(1f)) - val h = lerp(from.height, 2 * r, ((v - 0.25f) / 0.75f).coerceIn(0f, 1f)) - val k = 1f - 0.15f * sin(PI * v).toFloat() - drawRoundRect(pal.signal, Offset(a.x - w * k / 2, a.y - h * k / 2), Size(w * k, h * k), CornerRadius(minOf(w, h) * k / 2)) - } else { - val p = v - 1f - val c = Offset((a.x + b.x) / 2, minOf(a.y, b.y) - 80.dp.toPx()) - val q = 1f - p - val pos = Offset(q * q * a.x + 2 * q * p * c.x + p * p * b.x, q * q * a.y + 2 * q * p * c.y + p * p * b.y) - val d = Offset(2 * q * (c.x - a.x) + 2 * p * (b.x - c.x), 2 * q * (c.y - a.y) + 2 * p * (b.y - c.y)) - val stretch = sin(PI * p).toFloat() - val angle = atan2(d.y, d.x) * 180f / PI.toFloat() - rotate(angle, pos) { - val w = 2 * r * (1f + 0.5f * stretch) - val h = 2 * r * (1f - 0.25f * stretch) - drawOval(pal.signal, Offset(pos.x - w / 2, pos.y - h / 2), Size(w, h)) - } - } - } -} - -/** Pops (1.3 to 1, with one ring) each time a download's dot lands; for the count on the chip and the rail. */ -internal fun Modifier.landingPop(): Modifier = composed { +internal fun Modifier.landingPop(trigger: Int = StoresMotion.landed): Modifier = composed { val pal = LocalPalette.current val scale = remember { Animatable(1f) } val ring = remember { Animatable(1f) } - val seen = remember { intArrayOf(StoresMotion.landed) } - LaunchedEffect(StoresMotion.landed) { - if (StoresMotion.landed == seen[0]) return@LaunchedEffect - seen[0] = StoresMotion.landed + val seen = remember { intArrayOf(trigger) } + LaunchedEffect(trigger) { + if (trigger == seen[0]) return@LaunchedEffect + seen[0] = trigger coroutineScope { launch { scale.snapTo(1.3f); scale.animateTo(1f, Motion.sp(0.45f, 500f)) } launch { ring.snapTo(0f); ring.animateTo(1f, Motion.tw(400)) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt index a23c9ebaf..27c6d7ff1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt @@ -160,7 +160,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier var tab by rememberSaveable { mutableStateOf(if (SessionPrefs.storesOpenTab(ctx) == SessionPrefs.STORES_OPEN_STORE) "store" else "library") } var query by rememberSaveable { mutableStateOf("") } var openGame by rememberSaveable { mutableStateOf(null) } - var settings by rememberSaveable { mutableStateOf(false) } + // The cog's settings read the front end's state live while stepped out. + val liveState = androidx.compose.runtime.rememberUpdatedState(s) LaunchedEffect(StoresState.openDownloads) { if (StoresState.openDownloads) { chip = DOWNLOADS; StoresState.openDownloads = false } } val store = Store.byId(chip) // A tab this store does not have (Store or All on Amazon) reads as Library. @@ -337,7 +338,7 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier Box(Modifier.fillMaxWidth().padding(bottom = 8.dp)) { StoreTheme(Store.byId(focusedChip ?: chip)) { Rise(0) { StoreChips(chip, focusedChip, s.storeDownloadsActive, onStore = { st, at -> openModes(st, at) }, onDownloads = { if (chip != DOWNLOADS) pickChip(DOWNLOADS) else move("first") }, - onFocusChip = { focusedChip = it }, onSettings = { settings = true }) } + onFocusChip = { focusedChip = it }, onSettings = { at -> if (at != null) StoresMotion.ask(storesSettingsStep(at, liveState, a, pal.signal)) }) } } } val fade = with(androidx.compose.ui.platform.LocalDensity.current) { 14.dp.toPx() } @@ -475,7 +476,6 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier seenActive.clear(); seenActive.addAll(activeKeys) if (fresh.isNotEmpty()) StoresMotion.takeInstall()?.let { StoresMotion.fly(it) } } - if (settings) StoresSettingsDialog(s, a) { settings = false } StoresState.pendingInstall?.let { item -> InstallWhere(item) } } @@ -571,7 +571,7 @@ private fun SignInFloodLayer(hostAt: Offset) { */ @Composable private fun StoreChips( - selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: () -> Unit, + selected: String, focused: String?, active: Int, onStore: (Store, Rect?) -> Unit, onDownloads: () -> Unit, onSettings: (Rect?) -> Unit, /** The chip a pad's focus is on (null when it leaves the row): the page shows that store. */ onFocusChip: (String?) -> Unit, ) { @@ -621,75 +621,16 @@ private fun StoreChips( trail = if (active > 0) { { CountPill(active) } } else null, onFocused = onFocusChip, ) { onDownloads() } - IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings), onSettings) + var cogAt by remember { mutableStateOf(null) } + Box(Modifier.onGloballyPositioned { cogAt = it.boundsInRoot() }) { + IconChip(Icons.Filled.Settings, stringResource(R.string.stores_settings)) { onSettings(cogAt) } + } } // Over the chips: the fill is translucent, and the other chips' rest fill would hide it in flight. ChipGlide(keys.indexOf(highlighted), bounds[highlighted], tint) } } -/** - * The selection under the chips: [at] (in the row) of chip [index], in [tint]. A move to the next - * chip stretches it - the leading edge on the focus ring's lead spring, the trailing one a beat - * later on its trail spring - and a hop past a chip pinches it to a drop, carries it and opens it. - */ -@Composable -private fun BoxScope.ChipGlide(index: Int, at: Rect?, tint: State) { - val density = androidx.compose.ui.platform.LocalDensity.current - val left = remember { Animatable(Float.NaN) } - val right = remember { Animatable(0f) } - val pinch = remember { Animatable(0f) } - val top = remember { mutableStateOf(0f) } - val bottom = remember { mutableStateOf(0f) } - val was = remember { intArrayOf(-1) } - LaunchedEffect(index, at) { - val b = at ?: return@LaunchedEffect - top.value = b.top; bottom.value = b.bottom - val from = was[0] - was[0] = index - if (left.value.isNaN() || from == index || from < 0 || Motion.scale == 0f) { - left.snapTo(b.left); right.snapTo(b.right); pinch.snapTo(0f) - return@LaunchedEffect - } - coroutineScope { - if (kotlin.math.abs(index - from) > 1) { - // Too far to stretch: pinch into a drop where it is, carry it over, open it there. - pinch.animateTo(1f, Motion.tw(90)) - val l = launch { left.animateTo(b.left, Motion.sp(0.7f, 380f)) } - val r = launch { right.animateTo(b.right, Motion.sp(0.7f, 380f)) } - l.join(); r.join() - pinch.animateTo(0f, Motion.sp(0.6f, 500f)) - } else { - val forward = index > from - val (lead, trail) = if (forward) right to left else left to right - val leadTo = if (forward) b.right else b.left - val trailTo = if (forward) b.left else b.right - launch { lead.animateTo(leadTo, Motion.sp(0.62f, 700f)) } - launch { delay(Motion.ms(40).toLong()); trail.animateTo(trailTo, Motion.sp(0.78f, 360f)) } - } - } - } - androidx.compose.foundation.Canvas(Modifier.matchParentSize()) { - if (left.value.isNaN() || bottom.value <= top.value) return@Canvas - val l = minOf(left.value, right.value) - val r = maxOf(left.value, right.value) - val cx = (l + r) / 2f - val cy = (top.value + bottom.value) / 2f - val drop = with(density) { 6.dp.toPx() } - val p = pinch.value - val x0 = androidx.compose.ui.util.lerp(l, cx - drop, p) - val x1 = androidx.compose.ui.util.lerp(r, cx + drop, p) - val y0 = androidx.compose.ui.util.lerp(top.value, cy - drop, p) - val y1 = androidx.compose.ui.util.lerp(bottom.value, cy + drop, p) - val size = androidx.compose.ui.geometry.Size(x1 - x0, y1 - y0) - val corner = androidx.compose.ui.geometry.CornerRadius(minOf(size.width, size.height) / 2f) - val at0 = Offset(x0, y0) - // A drop in flight is solid; on a chip it is the chip's tinted fill and outline. - drawRoundRect(tint.value.copy(alpha = androidx.compose.ui.util.lerp(0.16f, 0.9f, p)), at0, size, corner) - drawRoundRect(tint.value.copy(alpha = 0.7f * (1f - p)), at0, size, corner, style = androidx.compose.ui.graphics.drawscope.Stroke(with(density) { 1.dp.toPx() })) - } -} - @Composable private fun StoreChip( label: String, on: Boolean, key: String, modifier: Modifier, lead: @Composable () -> Unit, @@ -945,7 +886,7 @@ private fun Storefront( * search field across the row, and [trailing] (the account) at the right, over a rule. */ @Composable -private fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) { +internal fun ModeHeader(title: String, query: String, onQuery: (String) -> Unit, placeholder: String, trailing: @Composable () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current // Open while it holds a query; an empty field closes again once focus leaves it. @@ -1001,6 +942,22 @@ internal fun storePalette(base: Palette, store: Store?): Palette { ) } +/** The Store section's accent: Flathub's teal, with its dark ink on it. */ +internal fun flathubPalette(base: Palette): Palette = Palette( + base.id, base.label, base.detail, base.background, base.surface, base.surfaceVariant, base.line, base.line2, + base.onBackground, base.onSurfaceVariant, primary = FlathubTeal, primary2 = Color(0xFF2E958D), onPrimary = FlathubInk, signal = FlathubTeal, + good = base.good, error = base.error, +) + +/** [content] with the focus ring, buttons, step-outs and the chip glide in Flathub's teal. */ +@Composable +internal fun FlathubTheme(content: @Composable () -> Unit) { + val base = LocalPalette.current + val p = remember(base) { flathubPalette(base) } + val scheme = MaterialTheme.colorScheme.copy(primary = p.primary, onPrimary = p.onPrimary) + CompositionLocalProvider(LocalPalette provides p) { MaterialTheme(colorScheme = scheme, typography = MaterialTheme.typography, content = content) } +} + /** [content] with the focus ring, buttons and step-outs in [store]'s colour. */ @Composable internal fun StoreTheme(store: Store?, content: @Composable () -> Unit) { @@ -1060,7 +1017,7 @@ private fun SignOutButton(store: Store, name: String) { /** The header's magnifier: a round button the search field opens out of. */ @Composable -private fun SearchIcon(description: String, onClick: () -> Unit) { +internal fun SearchIcon(description: String, onClick: () -> Unit) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current val src = remember { MutableInteractionSource() } diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3a99dc295..d22ccfba5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -642,9 +642,6 @@ Elige una app de inicio Elegir Apps de Linux (beta) - Tienda Flathub - En el menú. Algunas apps pueden no iniciarse; los registros están en Download/DroidDeck. - Instala apps y juegos de Linux desde Flathub Archivos Explorar y gestionar archivos Versiones de Proton diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 82bb88788..cac95004b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -518,9 +518,6 @@ Choisir l\'application d\'écran d\'accueil Choisir Applications Linux (bêta) - Boutique Flathub - Affiché dans le menu. Certaines applications peuvent ne pas se lancer ; les journaux se trouvent dans Download/DroidDeck. - Installer des applications et des jeux Linux depuis Flathub Fichiers Parcourir et gérer les fichiers Versions de Proton diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 771191479..a788642f6 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -638,9 +638,6 @@ ホームアプリを選択 選択 Linuxアプリ(ベータ版) - Flathubストア - メニューに表示されます。一部のアプリは起動できない場合があります。ログはDownload/DroidDeckにあります。 - FlathubからLinuxアプリとゲームをインストール ファイル ファイルの閲覧と管理 Protonバージョン diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 71e537e51..6d5ef3a49 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -638,9 +638,6 @@ 홈 앱 선택 선택 Linux 앱(베타) - Flathub 스토어 - 메뉴에 표시됩니다. 일부 앱은 실행되지 않을 수 있습니다. 로그는 Download/DroidDeck에 있습니다. - Flathub에서 Linux 앱 및 게임 설치 파일 파일 탐색 및 관리 Proton 버전 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 071cc5646..be7523541 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -596,9 +596,6 @@ Выбрать главный экран Выбрать Приложения Linux (бета) - Магазин Flathub - Показывается в меню. Некоторые приложения могут не запускаться; журналы в Download/DroidDeck. - Устанавливать приложения и игры Linux из Flathub Файлы Просмотр и управление файлами Версии Proton diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 95e14ffdf..4386993cb 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -638,9 +638,6 @@ 选择主屏幕应用 选择 Linux 应用(测试版) - Flathub 商店 - 显示在菜单中。部分应用可能无法启动;日志位于 Download/DroidDeck。 - 从 Flathub 安装 Linux 应用和游戏 文件 浏览和管理文件 Proton 版本 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 5c529e6c9..1cb3f71b5 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -638,9 +638,6 @@ 選擇主畫面應用程式 選擇 Linux 應用程式(測試版) - Flathub 商店 - 顯示在選單中。部分應用程式可能無法啟動;記錄位於 Download/DroidDeck。 - 從 Flathub 安裝 Linux 應用程式和遊戲 檔案 瀏覽及管理檔案 Proton 版本 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4b5c09a09..2078bafbb 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -638,9 +638,6 @@ 選擇主螢幕應用程式 選擇 Linux 應用程式(測試版) - Flathub 商店 - 顯示在選單中。部分應用程式可能無法啟動;日誌位於 Download/DroidDeck。 - 從 Flathub 安裝 Linux 應用程式和遊戲 檔案 瀏覽和管理檔案 Proton 版本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f9e1b604..4537ab369 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -658,9 +658,6 @@ Choose a Home app Choose Linux apps (beta) - Flathub Store - In the menu. Some apps may not start; logs are in Download/DroidDeck. - Install Linux apps and games from Flathub Files Browse and manage files Proton versions @@ -1993,7 +1990,6 @@ Steam Custom Stores - Show Stores in the rail Open a store on Download speed tier Stores engine @@ -2183,4 +2179,50 @@ %1$d already in Games %1$d already in Games + + B · Back to %1$s + the game + Play settings + + Graphics drivers + + This Proton + Every Proton + For every Proton + Available · %1$d + Checked %1$s + Check for new components and drivers + Get + %1$s · DXVK, VKD3D-Proton and FEX below are this Proton\'s + %1$s · tested + %1$s · untested + %1$s · unsupported + Keeps the recommended pair current + Auto · %1$s + Runtime and display drivers separately + Use %1$s? + Remove %1$s? + + Diagnostics + Ready to play + Not ready to play yet + runtime %1$s + process limit off + signed in as %1$s + When something needs you + Look + This device + Library art + + LINUX APPS + Flathub + Desktop apps for the Linux desktop, opened from Desktop. + Set up Flathub + Remove %1$s? + Its settings and files are kept. + Keep + + Size on disk + Uninstall the desktop? + All diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt new file mode 100644 index 000000000..8a9122505 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GlideShapeTest.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GlideShapeTest { + @Test fun restingAcrossCoversTheChip() { + val r = glideShape(10f, 90f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false) + assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f) + assertEquals(0f, r.top, 0.01f); assertEquals(40f, r.bottom, 0.01f) + } + + @Test fun edgesInEitherOrder() { + val r = glideShape(90f, 10f, 0f, 40f, pinch = 0f, drop = 6f, vertical = false) + assertEquals(10f, r.left, 0.01f); assertEquals(90f, r.right, 0.01f) + } + + @Test fun pinchedIsATwelveDpDropAtTheCentre() { + val r = glideShape(10f, 90f, 0f, 40f, pinch = 1f, drop = 6f, vertical = false) + assertEquals(12f, r.width, 0.01f); assertEquals(12f, r.height, 0.01f) + assertEquals(50f, r.center.x, 0.01f); assertEquals(20f, r.center.y, 0.01f) + } + + @Test fun verticalRunsTheAxisDown() { + val r = glideShape(100f, 160f, 4f, 84f, pinch = 0f, drop = 6f, vertical = true) + assertEquals(4f, r.left, 0.01f); assertEquals(84f, r.right, 0.01f) + assertEquals(100f, r.top, 0.01f); assertEquals(160f, r.bottom, 0.01f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt new file mode 100644 index 000000000..b25eea49e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/InPaneTest.kt @@ -0,0 +1,20 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.geometry.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class InPaneTest { + @Test fun aRailItemLeftOfThePaneStartsHalfInside() { + val o = inPane(Origin(Rect(-200f, 300f, -20f, 420f), 14f)) + assertEquals(-90f, o.bounds.left, 0.01f) + assertEquals(90f, o.bounds.right, 0.01f) + assertEquals(300f, o.bounds.top, 0.01f) + } + + @Test fun aControlInThePaneStaysPut() { + val origin = Origin(Rect(40f, 300f, 200f, 360f), 12f) + assertSame(origin, inPane(origin)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt new file mode 100644 index 000000000..a8581d893 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LayerStepTest.kt @@ -0,0 +1,24 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LayerStepTest { + @Test fun theStackRunsFromTheProtonDownToTheGpuDrivers() { + assertEquals(listOf(PROTON_TAB, "dxvk", "vkd3d", "fex", GPU_TAB), LAYERS) + } + + @Test fun rbStepsDownAndWraps() { + assertEquals("dxvk", layerStep(PROTON_TAB, 1)) + assertEquals(PROTON_TAB, layerStep(GPU_TAB, 1)) + } + + @Test fun lbStepsUpAndWraps() { + assertEquals("fex", layerStep(GPU_TAB, -1)) + assertEquals(GPU_TAB, layerStep(PROTON_TAB, -1)) + } + + @Test fun anUnknownLayerStartsFromTheTop() { + assertEquals("dxvk", layerStep("tab-from-an-older-build", 1)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt new file mode 100644 index 000000000..c5b8003b9 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/LiftSheetTest.kt @@ -0,0 +1,18 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LiftSheetTest { + @Test fun theSheetTopIs82dpDownA360dpPane() { + assertEquals(82f, sheetTop(360f, 56f, 16f, 3f), 0.01f) + assertEquals(164f, sheetTop(720f, 56f, 16f, 3f), 0.01f) + } + + @Test fun theButtonKeeps16dpAboveIt() { + // A short pane: the edge comes down so the button riding it still clears the top by 16. + val top = sheetTop(150f, 56f, 16f, 3f) + assertEquals(41f, top, 0.01f) + assertEquals(16f, top + 3f - 56f / 2f, 0.01f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt new file mode 100644 index 000000000..b54248f40 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/PageExitTest.kt @@ -0,0 +1,31 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PageExitTest { + @Test fun backToTheSameRailItemDrainsIntoTheControl() { + assertEquals(PageExit.Drain, pageExit("steam", "steam", PageLeave.Ordinary)) + } + + @Test fun anotherRailItemSinks() { + assertEquals(PageExit.Sink, pageExit("steam", "games", PageLeave.Ordinary)) + } + + @Test fun anotherPageSinks() { + // The GPU drivers row in Steam's settings opens Components over the same rail item. + assertEquals(PageExit.Sink, pageExit("steam", "components", PageLeave.Ordinary)) + } + + @Test fun aLinkIntoAnotherSectionHops() { + assertEquals(PageExit.Hop, pageExit("steam", "components", PageLeave.Link)) + } + + @Test fun drainingWinsWhenTheOwnerComesBackAnyway() { + assertEquals(PageExit.Drain, pageExit("desktop", "desktop", PageLeave.Link)) + } + + @Test fun aPageWithNoOwnerNeverDrains() { + assertEquals(PageExit.Sink, pageExit(null, "steam", PageLeave.Ordinary)) + } +}