mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
feat(ui): add game resource links (#1630)
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
@@ -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 & 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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user