feat(ui): add game resource links (#1630)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-26 14:53:21 -04:00
1 parent 830c753312
commit c8f650e167
5 files changed
+207

No files matched your search

@@ -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 ->
@@ -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<GameResource> {
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))
}
}
+8
View File
@@ -3204,6 +3204,14 @@
<string name="library_no_installed_matches">No installed games match these filters.</string>
<string name="library_browse_owned">Browse owned games</string>
<string name="game_tab_about">About</string>
<string name="game_resources_browser">Opens in your browser</string>
<string name="game_resource_pcgw" translatable="false">PCGamingWiki</string>
<string name="game_resource_guides">Guides</string>
<string name="game_resource_protondb" translatable="false">ProtonDB</string>
<string name="game_resource_protondb_detail">Linux / Proton compatibility reports</string>
<string name="game_resource_steam_cloud">Steam Cloud</string>
<string name="game_resource_steam_cloud_detail">For games with saves on your Steam account</string>
<string name="game_resource_search">Web search</string>
<string name="game_tab_achievements_count" translatable="false">%1$s %2$d/%3$d</string>
<string name="game_tab_launch">Launch</string>
<string name="game_tab_saves_cloud">Saves &amp; cloud</string>
@@ -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()
}
}
}
@@ -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())
}
}