diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt index ac8ecffcc..5350d2afa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameDetail.kt @@ -219,6 +219,9 @@ internal fun GameDetail( } } if (StoresMotion.heroLands) hero() else Rise(1) { hero() } + if (!s.shortcutPicker) Rise(2) { + GameResourcesRow(title.takeUnless { it == id }.orEmpty(), game, Modifier.padding(top = 6.dp)) + } if (tab != null) Rise(2) { Column(Modifier.fillMaxWidth().padding(top = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { val labels = tabs.map { t -> diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt new file mode 100644 index 000000000..78384480a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameResources.kt @@ -0,0 +1,90 @@ +package com.droiddeck.launcher.ui + +import androidx.annotation.StringRes +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.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +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.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import java.net.URLEncoder + +internal data class GameResource(@StringRes val label: Int, val url: String, @StringRes val detail: Int? = null) + +internal fun gameResources(title: String, game: Library.SteamGame?): List { + if (title.isBlank()) return emptyList() + val query = URLEncoder.encode(title.trim(), "UTF-8").replace("+", "%20") + val steamId = game?.takeIf { Library.fromSteamClient(it) && it.appId > 0 }?.appId + return buildList { + add(GameResource(R.string.game_resource_pcgw, "https://www.pcgamingwiki.com/w/index.php?search=$query")) + add(GameResource(R.string.game_resource_guides, "https://gamefaqs.gamespot.com/search?game=$query")) + if (steamId != null) { + add(GameResource(R.string.game_resource_protondb, "https://www.protondb.com/app/$steamId", R.string.game_resource_protondb_detail)) + add(GameResource(R.string.game_resource_steam_cloud, "https://store.steampowered.com/account/remotestorageapp/?appid=$steamId", R.string.game_resource_steam_cloud_detail)) + } + add(GameResource(R.string.game_resource_search, "https://www.google.com/search?q=$query")) + } +} + +@Composable +@OptIn(ExperimentalLayoutApi::class) +internal fun GameResourcesRow(title: String, game: Library.SteamGame?, modifier: Modifier = Modifier) { + val links = remember(title, game) { gameResources(title, game) } + if (links.isEmpty()) return + FlowRow(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + links.forEach { ResourceLink(it) } + } +} + +@Composable +private fun ResourceLink(link: GameResource) { + val ctx = LocalContext.current + val pal = LocalPalette.current + val source = remember { MutableInteractionSource() } + val hot = rememberHot(source) + val shape = RoundedCornerShape(8.dp) + val label = stringResource(link.label) + val description = listOfNotNull(label, link.detail?.let { stringResource(it) }, stringResource(R.string.game_resources_browser)).joinToString(". ") + val open = { openUrl(ctx, link.url) } + val tint = if (hot) pal.signal else pal.onSurfaceVariant + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier.paneItem("resource:${link.label}").heightIn(min = 44.dp).clip(shape) + .background(if (hot) pal.signal.copy(alpha = 0.08f) else Color.Transparent) + .glideBorder(hot, shape, pal.signal, restColor = Color.Transparent) + .semantics { contentDescription = description } + .hoverable(source).clickable(interactionSource = source, indication = LocalIndication.current, role = Role.Button, onClick = open) + .controllerConfirm(onClick = open).padding(horizontal = 8.dp), + ) { + Text(label, fontSize = 12.sp, fontWeight = FontWeight.Medium, color = tint, maxLines = 1) + Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = tint.copy(alpha = 0.65f), modifier = Modifier.size(11.dp)) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 840e26ebb..1399db1b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3204,6 +3204,14 @@ No installed games match these filters. Browse owned games About + Opens in your browser + PCGamingWiki + Guides + ProtonDB + Linux / Proton compatibility reports + Steam Cloud + For games with saves on your Steam account + Web search %1$s %2$d/%3$d Launch Saves & cloud diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GameResourcesRowTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GameResourcesRowTest.kt new file mode 100644 index 000000000..5793b394f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GameResourcesRowTest.kt @@ -0,0 +1,63 @@ +package com.droiddeck.launcher.ui + +import android.content.Intent +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.InputModeManager +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w360dp-h640dp-port", application = android.app.Application::class) +class GameResourcesRowTest { + @get:Rule val compose = createRobolectricComposeRule() + private val ctx get() = RuntimeEnvironment.getApplication() + private val game = Library.SteamGame(292030, "The Witcher 3", null, "/steam") + + @Test fun everyResourceIsReachableOnANarrowPageAndOpensTheExpectedBrowserUrl() { + compose.setContent { DroidDeckTheme { GameResourcesRow(game.name, game) } } + for (link in gameResources(game.name, game)) { + compose.onNodeWithText(ctx.getString(link.label)).assertIsDisplayed().performClick() + val intent = shadowOf(ctx).nextStartedActivity + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(link.url, intent.dataString) + } + } + + @Test fun controllerCanMoveBetweenLinksAndOpenAResource() { + lateinit var inputMode: InputModeManager + compose.setContent { DroidDeckTheme { + inputMode = LocalInputModeManager.current + GameResourcesRow(game.name, game) + } } + compose.runOnIdle { inputMode.requestInputMode(InputMode.Keyboard) } + compose.onNodeWithText(ctx.getString(R.string.game_resource_pcgw)).requestFocus().assertIsFocused().performKeyInput { pressKey(Key.DirectionRight) } + compose.onNodeWithText(ctx.getString(R.string.game_resource_guides)).assertIsFocused().performKeyInput { pressKey(Key.ButtonA) } + assertEquals(gameResources(game.name, game)[1].url, shadowOf(ctx).nextStartedActivity.dataString) + } + + @Test @Config(qualifiers = "w640dp-h360dp-land") + fun shortLandscapeWindowsKeepEveryResourceVisible() { + compose.setContent { DroidDeckTheme { GameResourcesRow(game.name, game) } } + for (link in gameResources(game.name, game)) { + compose.onNodeWithText(ctx.getString(link.label)).assertIsDisplayed() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GameResourcesTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GameResourcesTest.kt new file mode 100644 index 000000000..3fd423e59 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GameResourcesTest.kt @@ -0,0 +1,43 @@ +package com.droiddeck.launcher.ui + +import com.droiddeck.launcher.R +import com.droiddeck.launcher.frontend.Library +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URI +import java.net.URLDecoder + +class GameResourcesTest { + @Test fun searchesPreserveUnicodeAndEscapeQueryDelimiters() { + val title = "Ōkami HD & friends + 100% #1?" + val links = gameResources(" $title ", null) + assertEquals(3, links.size) + links.forEach { link -> + val uri = URI(link.url) + assertEquals("https", uri.scheme) + assertEquals(null, uri.fragment) + assertEquals(title, URLDecoder.decode(uri.rawQuery.substringAfter('='), "UTF-8")) + } + } + + @Test fun onlySteamClientGamesGetAppSpecificSteamResources() { + val steam = Library.SteamGame(292030, "The Witcher 3", null, "/steam") + val links = gameResources(steam.name, steam) + assertEquals("https://www.protondb.com/app/292030", links.single { it.label == R.string.game_resource_protondb }.url) + assertEquals("https://store.steampowered.com/account/remotestorageapp/?appid=292030", links.single { it.label == R.string.game_resource_steam_cloud }.url) + val others = listOf( + Library.SteamGame(292030, steam.name, null, Library.ADDED), + Library.SteamGame(292030, steam.name, null, Library.ADDED, source = "gog", storeId = "292030"), + Library.SteamGame(292030, steam.name, null, Library.XBOX_CLOUD, source = "xbox"), + Library.SteamGame(-1, steam.name, null, "/steam"), + ) + others.forEach { game -> + assertEquals(3, gameResources(game.name, game).size) + } + } + + @Test fun loadingPagesHaveNoResourcesUntilTheyHaveATitle() { + assertTrue(gameResources(" ", null).isEmpty()) + } +}