mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat(): Achievements UI for GOG & Epic game details page (#641)
* feat(): gog & epic achievements UI * feat(): AI-generated translations - Please get review for these * build_local: run on macOS hosts with Docker Desktop Two steps of tools/build_local.sh failed on a Mac: - The uruntime staging runs on the host, where macOS's BSD install has no -D, so `install -Dm644` stopped with "No such file or directory". The folders are now made first and the files copied with install -m644. - Inside the cross-compile container, Docker Desktop's VirtioFS share now and then refuses the chmod that install -m makes (EPERM, on a different file each run, e.g. "setting permissions for .../droiddeck-esync: Operation not permitted"). The overlay and desktop scripts are copied with a plain write instead. Nothing is lost: the APK keeps no file modes, and the app sets them when it stages the runtime. * feat(): adjust the store * feat(): store-achievements adjustment for UI * feat(): translation fixes and separate hidden achievements * Show all achievements directly on store game details --------- Co-authored-by: xXJsonDeruloXx <[email protected]>
This commit is contained in:
1 parent
a249e896f7
commit
a7662ae13a
21 files changed
+922
-15
No files matched your search
@@ -287,6 +287,9 @@ dependencies {
|
||||
implementation platform('androidx.compose:compose-bom:2024.02.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
// @Preview for the composables; the tooling that renders them is a debug-only dependency.
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
// The File Manager, Bannerlator's: its icon set, image thumbnails and the coroutines it
|
||||
// copies on. Same versions as Bannerlator so the screen stays a straight copy.
|
||||
|
||||
@@ -28,10 +28,12 @@ import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -41,6 +43,12 @@ import com.droiddeck.launcher.core.TextureFiltering
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.agent.AgentGuest
|
||||
import com.droiddeck.launcher.frontend.LibraryCache
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreLaunchRequests
|
||||
import com.droiddeck.launcher.ui.SessionAchievements
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.ScreenEffectLooks
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
@@ -281,6 +289,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var padType by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
/** The game in front, when its source has achievements. */
|
||||
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
|
||||
private var achievementsOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var upscaler by mutableStateOf(0)
|
||||
private var upscaleSharpness by mutableStateOf(75)
|
||||
@@ -495,6 +506,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
when {
|
||||
::loading.isInitialized && loading.ended -> finish()
|
||||
drawerOpen -> drawerOpen = false
|
||||
achievementsOpen -> closeAchievements()
|
||||
pcKeyboardOpen -> pcKeyboardOpen = false
|
||||
else -> routeBackAction()
|
||||
}
|
||||
@@ -515,7 +527,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
defaultFocusHighlightEnabled = false
|
||||
setContent {
|
||||
// The drawer, the PC keyboard and picture in picture take the mouse back.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
|
||||
DroidDeckTheme {
|
||||
if (pipUi) {
|
||||
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
|
||||
@@ -550,6 +562,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
refreshAchievementsInFront()
|
||||
padBridge?.releaseAll()
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val requested = sessionOverlay.requestFocus()
|
||||
@@ -636,6 +649,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
|
||||
onAchievements = achievementsInFront?.let { { drawerOpen = false; achievementsOpen = true } },
|
||||
achievementsProgress = achievementsInFront?.progress,
|
||||
// Only where the session set the pad up to be switched (SessionService: deckPad).
|
||||
padType = if (SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad) padType else null,
|
||||
onPadType = { id ->
|
||||
@@ -732,6 +747,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
|
||||
onGameComponent = ::setDrawerGameComponent,
|
||||
))
|
||||
// The panel takes the pad, as the PC keyboard does.
|
||||
LaunchedEffect(achievementsOpen) {
|
||||
if (achievementsOpen) {
|
||||
padBridge?.releaseAll()
|
||||
withFrameNanos { }
|
||||
sessionOverlay.requestFocus()
|
||||
}
|
||||
}
|
||||
achievementsInFront?.takeIf { achievementsOpen }?.let { inFront ->
|
||||
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
|
||||
}
|
||||
if (SessionState.suspended) SessionPausedOverlay(
|
||||
title = pausedTitle(),
|
||||
onResume = { SessionService.resume(this@SessionActivity) },
|
||||
@@ -772,6 +798,37 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
padBridge?.triggerQam()
|
||||
}
|
||||
|
||||
/** Re-reads so the drawer's count picks up the panel's fresh list. */
|
||||
private fun closeAchievements() {
|
||||
achievementsOpen = false
|
||||
refreshAchievementsInFront()
|
||||
}
|
||||
|
||||
private fun refreshAchievementsInFront() {
|
||||
if (SessionState.mode != SessionService.MODE_STEAM) { achievementsInFront = null; return }
|
||||
Thread({
|
||||
val found = runCatching { resolveAchievementsInFront() }.onFailure { Log.w(TAG, "achievements in front: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
uiHandler.post { achievementsInFront = found }
|
||||
}, "achievements-in-front").start()
|
||||
}
|
||||
|
||||
/** Steam's focused game (a Steam title means none), else the last store launch, else the session's own game. */
|
||||
private fun resolveAchievementsInFront(): AchievementsInFront? {
|
||||
val games = LibraryCache.load(this)
|
||||
fun storeOf(g: Library.SteamGame) = Store.byId(g.source)?.let { st -> g.storeId?.let { st to it } }
|
||||
val byKey = games.mapNotNull { g -> g.profileKey.toLongOrNull()?.let { it to g } }.toMap()
|
||||
val focused = runCatching { AgentGuest.activeSteamAppId(this, byKey.keys) }.getOrNull()?.let(byKey::get)
|
||||
val url = SessionState.steamUrl?.substringAfter("rungameid/", "")?.takeIf { it.isNotEmpty() }
|
||||
val (store, id) = (if (focused != null) storeOf(focused)
|
||||
else StoreLaunchRequests.lastLaunched ?: games.firstOrNull { it.gameIdString == url }?.let(::storeOf))
|
||||
?: return null
|
||||
val source = StoreAchievements.forSource(store.id) ?: return null
|
||||
val sidecar = StoreGameSidecar.find(this, store, id) ?: return null
|
||||
val game = StoreAchievements.Game.of(sidecar)
|
||||
val saved = source.cached(this, game)?.takeIf { it.isNotEmpty() }
|
||||
return AchievementsInFront(game, sidecar.title, saved?.let { l -> l.count { it.unlocked } to l.size })
|
||||
}
|
||||
|
||||
private fun togglePcKeyboard() {
|
||||
drawerOpen = false
|
||||
keyboard?.hide()
|
||||
@@ -1662,7 +1719,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (pcKeyboardOpen && fromController) return super.dispatchKeyEvent(event)
|
||||
if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event)
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
|
||||
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
|
||||
// activity, which opens the drawer.
|
||||
@@ -1715,7 +1772,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return true
|
||||
}
|
||||
if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection()
|
||||
if (pcKeyboardOpen && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event)
|
||||
if (padBridge?.onMotionEvent(event) == true) return true
|
||||
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
@@ -2153,7 +2210,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun updatePointerCapture() {
|
||||
if (!::surfaceView.isInitialized) return
|
||||
val view = window.decorView
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
|
||||
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !achievementsOpen && !pipUi && mouseAttached()
|
||||
if (want == view.hasPointerCapture()) return
|
||||
if (want) view.requestPointerCapture() else view.releasePointerCapture()
|
||||
}
|
||||
@@ -2325,6 +2382,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (show) {
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
pendingBackAction?.let(uiHandler::removeCallbacks)
|
||||
pendingBackAction = null
|
||||
releaseDrawerDirection()
|
||||
@@ -2452,6 +2510,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// drawer's dim layer can otherwise remain over Steam when this activity returns.
|
||||
drawerOpen = false
|
||||
pcKeyboardOpen = false
|
||||
achievementsOpen = false
|
||||
(getSystemService(INPUT_SERVICE) as? InputManager)?.unregisterInputDeviceListener(deviceListener)
|
||||
releaseDrawerDirection()
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
@@ -2626,3 +2685,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** [progress]: (unlocked, total) from the cached list. */
|
||||
private class AchievementsInFront(val game: StoreAchievements.Game, val title: String, val progress: Pair<Int, Int>?)
|
||||
@@ -137,7 +137,7 @@ object CloudSaves {
|
||||
val galaxy = GogAuth.validToken(context)
|
||||
val clientId = gogClientId(context, id, folder)
|
||||
if (userId.isEmpty() || galaxy == null || clientId == null) null
|
||||
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId, account?.optString("refresh_token").orEmpty()) ?: galaxy)
|
||||
else GogCloud(userId, clientId, gogScopedToken(context, id, clientId) ?: galaxy)
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context)
|
||||
@@ -148,17 +148,9 @@ object CloudSaves {
|
||||
}
|
||||
|
||||
/** GOG cloud storage wants a token issued to the game's own client: the Galaxy refresh token, exchanged with the game's id and secret. */
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String, refresh: String): String? {
|
||||
private fun gogScopedToken(context: Context, id: String, clientId: String): String? {
|
||||
val secret = GogPrefs.get(context).getString("client_secret_$id", null)?.ifEmpty { null } ?: return null
|
||||
if (refresh.isEmpty()) return null
|
||||
return try {
|
||||
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
|
||||
val c = URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(secret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}").openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 15_000; c.readTimeout = 15_000
|
||||
c.setRequestProperty("User-Agent", "GOG Galaxy")
|
||||
if (c.responseCode != 200) { c.disconnect(); return null }
|
||||
JSONObject(c.inputStream.bufferedReader().readText()).optString("access_token").ifEmpty { null }.also { c.disconnect() }
|
||||
} catch (e: Exception) { null }
|
||||
return GogAuth.gameToken(context, clientId, secret)?.accessToken
|
||||
}
|
||||
|
||||
// ---- sync -----------------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.epic.EpicAchievements
|
||||
import com.droiddeck.launcher.stores.gog.GogAchievements
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A source's achievements with the account's progress, the last good answer cached on disk. Blocking.
|
||||
* [source] is the library's id for where a game came from ("gog", "epic"; "steam" once it has one).
|
||||
*/
|
||||
abstract class StoreAchievements(val source: String, val label: String) {
|
||||
|
||||
/** [extra]: what the source needs to find the game (Epic: "namespace"). */
|
||||
data class Game(val source: String, val id: String, val extra: Map<String, String> = emptyMap()) {
|
||||
companion object {
|
||||
fun of(item: CatalogItem) = Game(item.store.id, item.id, item.extra)
|
||||
fun of(sidecar: StoreGameSidecar) = Game(sidecar.store.id, sidecar.id, sidecar.extra)
|
||||
}
|
||||
}
|
||||
|
||||
class Achievement(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val hidden: Boolean,
|
||||
val unlocked: Boolean,
|
||||
/** Unix seconds; 0 when locked or unknown. */
|
||||
val unlockedAt: Long,
|
||||
val icon: String?,
|
||||
val iconLocked: String?,
|
||||
)
|
||||
|
||||
sealed class Result {
|
||||
class Found(val list: List<Achievement>) : Result()
|
||||
class Failed(val reason: Reason) : Result()
|
||||
}
|
||||
|
||||
enum class Reason { SIGNED_OUT, UNREACHABLE, NO_SERVICE }
|
||||
|
||||
protected abstract fun request(context: Context, game: Game): Result
|
||||
|
||||
fun fetch(context: Context, game: Game): Result {
|
||||
val result = runCatching { request(context, game) }.getOrElse { e ->
|
||||
Log.w(TAG, "$source:${game.id}: ${e.javaClass.simpleName}")
|
||||
Result.Failed(Reason.UNREACHABLE)
|
||||
}
|
||||
if (result is Result.Found) runCatching {
|
||||
cacheFile(context, game).apply { parentFile?.mkdirs() }.writeText(toJson(result.list).toString())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun cached(context: Context, game: Game): List<Achievement>? = runCatching {
|
||||
val file = cacheFile(context, game)
|
||||
if (!file.exists()) null else fromJson(JSONArray(file.readText()))
|
||||
}.getOrNull()
|
||||
|
||||
private fun cacheFile(context: Context, game: Game) =
|
||||
File(context.filesDir, "achievements/$source/${game.id.replace(Regex("[^A-Za-z0-9._-]"), "_")}.json")
|
||||
|
||||
protected fun locale(): String = Locale.getDefault().toLanguageTag()
|
||||
|
||||
/** ISO-8601 (including GOG's "+0000" offsets) to Unix seconds; 0 if unreadable. */
|
||||
protected fun epochSeconds(iso: String?): Long {
|
||||
if (iso.isNullOrBlank()) return 0
|
||||
val normalized = iso.replace(Regex("([+-]\\d{2})(\\d{2})$"), "$1:$2")
|
||||
return runCatching { OffsetDateTime.parse(normalized).toEpochSecond() }
|
||||
.recoverCatching { Instant.parse(normalized).epochSecond }
|
||||
.getOrDefault(0)
|
||||
}
|
||||
|
||||
private fun toJson(list: List<Achievement>) = JSONArray().apply {
|
||||
for (a in list) put(
|
||||
JSONObject().put("id", a.id).put("name", a.name).put("description", a.description).put("hidden", a.hidden)
|
||||
.put("unlocked", a.unlocked).put("unlockedAt", a.unlockedAt).put("icon", a.icon ?: "").put("iconLocked", a.iconLocked ?: ""),
|
||||
)
|
||||
}
|
||||
|
||||
private fun fromJson(arr: JSONArray) = List(arr.length()) { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
Achievement(
|
||||
id = o.getString("id"), name = o.optString("name"), description = o.optString("description"),
|
||||
hidden = o.optBoolean("hidden"), unlocked = o.optBoolean("unlocked"), unlockedAt = o.optLong("unlockedAt"),
|
||||
icon = o.optString("icon").ifEmpty { null }, iconLocked = o.optString("iconLocked").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StoreAchievements"
|
||||
|
||||
private val all: List<StoreAchievements> by lazy { listOf(GogAchievements, EpicAchievements) }
|
||||
|
||||
/** Null for a source without achievements. */
|
||||
fun forSource(source: String?): StoreAchievements? = all.firstOrNull { it.source == source }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher.stores
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -90,6 +91,12 @@ class StoreGameSidecar(
|
||||
|
||||
fun file(folder: File): File = File(folder, FILE_NAME)
|
||||
|
||||
/** An installed game's sidecar; walks the install folders. */
|
||||
fun find(context: Context, store: Store, id: String): StoreGameSidecar? =
|
||||
StoreInstallRoot.gameFolders(context.applicationContext).asSequence()
|
||||
.mapNotNull { read(it) }
|
||||
.firstOrNull { it.store == store && it.id == id }
|
||||
|
||||
/**
|
||||
* Changes an Epic game's launch choices on disk: reads the sidecar fresh (never a copy held
|
||||
* by a screen), applies [change], writes it, and reads it back. The written sidecar, or null
|
||||
|
||||
@@ -19,6 +19,9 @@ object StoreLaunchRequests {
|
||||
private val lock = Any()
|
||||
private var observer: FileObserver? = null
|
||||
|
||||
/** The store game this session last started; covers Steam's own Play button. */
|
||||
@Volatile var lastLaunched: Pair<Store, String>? = null
|
||||
|
||||
fun dir(context: Context) = File(LinuxRuntime.sessionRoot(context), "stores")
|
||||
|
||||
/** Before each session: nothing of the last one's requests; then watch for new ones. */
|
||||
@@ -28,6 +31,7 @@ object StoreLaunchRequests {
|
||||
observer?.stopWatching()
|
||||
val root = dir(app)
|
||||
root.deleteRecursively()
|
||||
lastLaunched = null
|
||||
val req = File(root, "req").apply { mkdirs() }
|
||||
File(root, "resp").mkdirs()
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -59,6 +63,7 @@ object StoreLaunchRequests {
|
||||
val id = request.optString("id")
|
||||
if (id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-id")
|
||||
else {
|
||||
lastLaunched = Store.EPIC to id
|
||||
val code = StoreLaunch.epicCode(app, id)
|
||||
JSONObject().put("ok", true).put("code", code.written).put("reason", code.reason)
|
||||
}
|
||||
@@ -70,6 +75,7 @@ object StoreLaunchRequests {
|
||||
val id = request.optString("id")
|
||||
if (store == null || id.isEmpty()) JSONObject().put("ok", false).put("reason", "no-game")
|
||||
else {
|
||||
lastLaunched = store to id
|
||||
// Marked before the game runs: whatever ends it, an upload follows (CloudSaves.uploadDirty).
|
||||
CloudSaves.markDirty(app, store, id)
|
||||
CloudSaves.downloadOrDefer(app, store, id).let { JSONObject().put("ok", it.ok).put("result", it.result).put("files", it.files).put("reason", it.reason) }
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.droiddeck.launcher.stores.epic
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Epic's GraphQL: the sandbox's achievements, then the player's records in it. */
|
||||
object EpicAchievements : StoreAchievements(Store.EPIC.id, Store.EPIC.shortLabel) {
|
||||
private const val GRAPHQL = "https://launcher.store.epicgames.com/graphql"
|
||||
private const val USER_AGENT = "EpicGamesLauncher/14.0.8-22004686+++Portal+Release-Live"
|
||||
|
||||
override fun request(context: Context, game: Game): Result {
|
||||
val namespace = game.extra["namespace"].orEmpty()
|
||||
if (namespace.isEmpty()) return Result.Failed(Reason.NO_SERVICE)
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
|
||||
val account = EpicCredentialStore.load(context)?.accountId.orEmpty()
|
||||
val defs = query(token, GAME_ACHIEVEMENTS, JSONObject().put("sandboxId", namespace).put("locale", locale()))
|
||||
?: return Result.Failed(Reason.UNREACHABLE)
|
||||
val list = defs.optJSONObject("data")?.optJSONObject("Achievement")?.optJSONObject("productAchievementsRecordBySandbox")
|
||||
?.optJSONArray("achievements") ?: return Result.Found(emptyList())
|
||||
val unlocked = if (account.isEmpty()) emptyMap() else unlockTimes(token, account, namespace)
|
||||
val out = ArrayList<Achievement>()
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.optJSONObject(i)?.optJSONObject("achievement") ?: continue
|
||||
val id = a.optString("name")
|
||||
if (id.isEmpty()) continue
|
||||
val at = unlocked[id]
|
||||
val done = at != null
|
||||
out += Achievement(
|
||||
id = id,
|
||||
name = (if (done) a.optString("unlockedDisplayName") else a.optString("lockedDisplayName"))
|
||||
.ifEmpty { a.optString("unlockedDisplayName") }.ifEmpty { id },
|
||||
description = (if (done) a.optString("unlockedDescription") else a.optString("lockedDescription"))
|
||||
.ifEmpty { a.optString("unlockedDescription") },
|
||||
hidden = a.optBoolean("hidden"),
|
||||
unlocked = done,
|
||||
unlockedAt = at ?: 0,
|
||||
icon = a.optString("unlockedIconLink").ifEmpty { null },
|
||||
iconLocked = a.optString("lockedIconLink").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
return Result.Found(out)
|
||||
}
|
||||
|
||||
/** Achievement name to unlock time, for the ones the player has. */
|
||||
private fun unlockTimes(token: String, account: String, namespace: String): Map<String, Long> {
|
||||
val out = HashMap<String, Long>()
|
||||
val records = query(token, PLAYER_ACHIEVEMENTS, JSONObject().put("epicAccountId", account).put("sandboxId", namespace))
|
||||
?.optJSONObject("data")?.optJSONObject("PlayerAchievement")?.optJSONObject("playerAchievementGameRecordsBySandbox")
|
||||
?.optJSONArray("records") ?: return out
|
||||
for (r in 0 until records.length()) {
|
||||
val player = records.optJSONObject(r)?.optJSONArray("playerAchievements") ?: continue
|
||||
for (i in 0 until player.length()) {
|
||||
val pa = player.optJSONObject(i)?.optJSONObject("playerAchievement") ?: continue
|
||||
if (pa.optBoolean("unlocked")) out[pa.optString("achievementName")] = epochSeconds(pa.optString("unlockDate"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun query(token: String, query: String, variables: JSONObject): JSONObject? =
|
||||
StoreNet.postJson(GRAPHQL, JSONObject().put("query", query).put("variables", variables).toString(), bearer = token, userAgent = USER_AGENT)
|
||||
?.let(::JSONObject)
|
||||
|
||||
private val GAME_ACHIEVEMENTS = """
|
||||
query Achievement(${'$'}sandboxId: String!, ${'$'}locale: String!) {
|
||||
Achievement {
|
||||
productAchievementsRecordBySandbox(sandboxId: ${'$'}sandboxId, locale: ${'$'}locale) {
|
||||
achievements {
|
||||
achievement {
|
||||
name hidden unlockedDisplayName lockedDisplayName unlockedDescription lockedDescription
|
||||
unlockedIconLink lockedIconLink XP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val PLAYER_ACHIEVEMENTS = """
|
||||
query PlayerAchievement(${'$'}epicAccountId: String!, ${'$'}sandboxId: String!) {
|
||||
PlayerAchievement {
|
||||
playerAchievementGameRecordsBySandbox(epicAccountId: ${'$'}epicAccountId, sandboxId: ${'$'}sandboxId) {
|
||||
records { playerAchievements { playerAchievement { achievementName unlocked unlockDate XP } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.droiddeck.launcher.stores.gog
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreLog
|
||||
import com.droiddeck.launcher.stores.StoreNet
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.InflaterInputStream
|
||||
|
||||
/** gameplay.gog.com, with a token scoped to the game's own client. */
|
||||
object GogAchievements : StoreAchievements(Store.GOG.id, Store.GOG.shortLabel) {
|
||||
private const val TAG = "GogAchievements"
|
||||
|
||||
override fun request(context: Context, game: Game): Result {
|
||||
val galaxy = GogAuth.validToken(context) ?: return Result.Failed(Reason.SIGNED_OUT)
|
||||
val (clientId, clientSecret) = client(context, game.id, galaxy) ?: return Result.Failed(Reason.NO_SERVICE)
|
||||
val scoped = GogAuth.gameToken(context, clientId, clientSecret) ?: return Result.Failed(Reason.UNREACHABLE)
|
||||
val body = StoreNet.get(
|
||||
"https://gameplay.gog.com/clients/$clientId/users/${scoped.userId}/achievements",
|
||||
bearer = scoped.accessToken, userAgent = GogAuth.GALAXY_UA, headers = mapOf("X-Gog-Lc" to locale()),
|
||||
) ?: return Result.Failed(Reason.UNREACHABLE)
|
||||
val items = body.takeIf { it.isNotBlank() }?.let(::JSONObject)?.optJSONArray("items") ?: return Result.Found(emptyList())
|
||||
val out = ArrayList<Achievement>()
|
||||
for (i in 0 until items.length()) {
|
||||
val item = items.optJSONObject(i) ?: continue
|
||||
val id = item.optString("achievement_key").ifEmpty { item.optString("achievement_id") }
|
||||
if (id.isEmpty()) continue
|
||||
val date = item.optString("date_unlocked").takeIf { it.isNotBlank() && it != "null" }
|
||||
out += Achievement(
|
||||
id = id,
|
||||
name = item.optString("name").ifEmpty { id },
|
||||
description = item.optString("description"),
|
||||
hidden = !item.optBoolean("visible", true),
|
||||
unlocked = date != null,
|
||||
unlockedAt = date?.let(::epochSeconds) ?: 0,
|
||||
icon = item.optString("image_url_unlocked").ifEmpty { item.optString("image_url_locked") }.ifEmpty { null },
|
||||
iconLocked = item.optString("image_url_locked").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
return Result.Found(out)
|
||||
}
|
||||
|
||||
/** The game's client id and secret: saved at install, else from the newest build's manifest. */
|
||||
private fun client(context: Context, productId: String, token: String): Pair<String, String>? {
|
||||
val prefs = GogPrefs.get(context)
|
||||
val savedId = prefs.getString("client_id_$productId", null)?.ifEmpty { null }
|
||||
val savedSecret = prefs.getString("client_secret_$productId", null)?.ifEmpty { null }
|
||||
if (savedId != null && savedSecret != null) return savedId to savedSecret
|
||||
val builds = StoreNet.get("https://content-system.gog.com/products/$productId/os/windows/builds?generation=2", bearer = token)
|
||||
?.let(::JSONObject) ?: return null
|
||||
val link = builds.optJSONArray("items")?.optJSONObject(0)?.optString("link")?.ifEmpty { null } ?: return null
|
||||
val manifest = bytes(link, token)?.let { JSONObject(inflate(it)) } ?: return null
|
||||
val id = manifest.optString("clientId").ifEmpty { return null }
|
||||
val secret = manifest.optString("clientSecret").ifEmpty { return null }
|
||||
prefs.edit().putString("client_id_$productId", id).putString("client_secret_$productId", secret).apply()
|
||||
return id to secret
|
||||
}
|
||||
|
||||
private fun bytes(url: String, bearer: String): ByteArray? {
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 20_000; readTimeout = 20_000
|
||||
setRequestProperty("Authorization", "Bearer $bearer")
|
||||
}
|
||||
if (conn.responseCode !in 200..299) null else conn.inputStream.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "GET failed ${StoreLog.redactUrl(url)}: ${e.javaClass.simpleName}")
|
||||
null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Gen2 manifests are zlib-compressed; plain JSON passes through. */
|
||||
private fun inflate(data: ByteArray): String =
|
||||
if (data.isNotEmpty() && data[0] == '{'.code.toByte()) String(data, Charsets.UTF_8)
|
||||
else InflaterInputStream(data.inputStream()).use { String(it.readBytes(), Charsets.UTF_8) }
|
||||
}
|
||||
@@ -87,6 +87,30 @@ object GogAuth {
|
||||
}
|
||||
}
|
||||
|
||||
class GameToken(val accessToken: String, val userId: String)
|
||||
|
||||
/** The Galaxy refresh token exchanged for one scoped to a game's own client. Blocking. */
|
||||
fun gameToken(context: Context, clientId: String, clientSecret: String): GameToken? {
|
||||
val json = StoreAccounts.read(context, Store.GOG) ?: return null
|
||||
val refresh = json.optString("refresh_token", "").ifEmpty { return null }
|
||||
var conn: HttpURLConnection? = null
|
||||
return try {
|
||||
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
|
||||
conn = (URL("https://auth.gog.com/token?client_id=${enc(clientId)}&client_secret=${enc(clientSecret)}&grant_type=refresh_token&refresh_token=${enc(refresh)}")
|
||||
.openConnection() as HttpURLConnection).apply { connectTimeout = 15_000; readTimeout = 15_000 }
|
||||
conn.setRequestProperty("User-Agent", "GOG Galaxy")
|
||||
if (conn.responseCode != 200) { Log.w(TAG, "game token: HTTP ${conn.responseCode}"); return null }
|
||||
val o = JSONObject(conn.inputStream.bufferedReader().readText())
|
||||
val access = o.optString("access_token").ifEmpty { return null }
|
||||
GameToken(access, o.optString("user_id").ifEmpty { json.optString("user_id") })
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "game token failed: ${e.javaClass.simpleName}")
|
||||
null
|
||||
} finally {
|
||||
runCatching { conn?.disconnect() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Hosts the GOG login page hands off to for a social sign-in; they refuse an embedded browser's identity. */
|
||||
fun isIdentityProviderHost(host: String?): Boolean {
|
||||
val h = host?.lowercase() ?: return false
|
||||
|
||||
@@ -223,6 +223,10 @@ class DrawerActions(
|
||||
val onHardwareKeyboard: () -> Unit,
|
||||
val onSteamMenu: (() -> Unit)?,
|
||||
val onQam: (() -> Unit)?,
|
||||
/** Null hides the achievements row. */
|
||||
val onAchievements: (() -> Unit)? = null,
|
||||
/** (unlocked, total), or null when unknown. */
|
||||
val achievementsProgress: Pair<Int, Int>? = null,
|
||||
val onOsc: (String) -> Unit,
|
||||
/** Edits the Steam touch layout of the game in front, when Steam's controls are up. */
|
||||
val onEditSteamTouch: (() -> Unit)? = null,
|
||||
@@ -409,6 +413,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
}
|
||||
StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true }
|
||||
}
|
||||
a.onAchievements?.let { show ->
|
||||
DrawerAchievementsRow(a.achievementsProgress, Modifier.padding(top = if (short) 6.dp else 10.dp).then(focus.track(page, "achievements"))) {
|
||||
host.open = null; show()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
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.PaddingValues
|
||||
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
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EmojiEvents
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoreAchievements.Achievement
|
||||
import com.droiddeck.launcher.stores.StoreAchievements.Reason
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
private val TrophyGold = Color(0xFFF5C542)
|
||||
|
||||
internal class AchievementsState(val list: List<Achievement>?, val reason: Reason?, val reading: Boolean) {
|
||||
val done: Int get() = list?.count { it.unlocked } ?: 0
|
||||
val all: Boolean get() = !list.isNullOrEmpty() && list.all { it.unlocked }
|
||||
|
||||
companion object { val READING = AchievementsState(null, null, true) }
|
||||
}
|
||||
|
||||
/** The cached list at once, then the store's answer (the cache stays if the store fails). */
|
||||
@Composable
|
||||
internal fun rememberAchievements(game: StoreAchievements.Game?): AchievementsState? {
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
var state by remember(game) { mutableStateOf(if (game == null) null else AchievementsState.READING) }
|
||||
LaunchedEffect(game) {
|
||||
if (game == null) return@LaunchedEffect
|
||||
val source = StoreAchievements.forSource(game.source) ?: return@LaunchedEffect
|
||||
val cached = withContext(Dispatchers.IO) { source.cached(ctx, game) }
|
||||
if (cached != null) state = AchievementsState(cached, null, true)
|
||||
state = when (val r = withContext(Dispatchers.IO) { source.fetch(ctx, game) }) {
|
||||
is StoreAchievements.Result.Found -> AchievementsState(r.list, null, false)
|
||||
is StoreAchievements.Result.Failed -> AchievementsState(cached, r.reason, false)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun achievementsNote(state: AchievementsState, label: String): String? = when {
|
||||
!state.list.isNullOrEmpty() -> null
|
||||
state.reading -> stringResource(R.string.achievements_reading)
|
||||
state.reason == Reason.SIGNED_OUT -> stringResource(R.string.achievements_signed_out, label)
|
||||
state.reason == Reason.UNREACHABLE -> stringResource(R.string.achievements_unreachable, label)
|
||||
else -> stringResource(R.string.achievements_none, label)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun Trophy(size: Dp = 18.dp, modifier: Modifier = Modifier) {
|
||||
Icon(Icons.Filled.EmojiEvents, stringResource(R.string.achievements_all), modifier.size(size), tint = TrophyGold)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AchievementsProgress(state: AchievementsState, compact: Boolean = false) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val list = state.list ?: return
|
||||
if (list.isEmpty()) return
|
||||
Column(verticalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.achievements_count, state.done, list.size), fontSize = 13.sp, color = colors.onSurfaceVariant)
|
||||
if (state.all) Trophy(16.dp)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { state.done / list.size.toFloat() },
|
||||
color = pal.signal, trackColor = pal.line,
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AchievementGroupLabel(label: String) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(top = 6.dp, bottom = 2.dp)) {
|
||||
Text(label.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant)
|
||||
Box(Modifier.weight(1f).height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
|
||||
/** Unlocked (newest first), locked, then hidden ones not yet unlocked. */
|
||||
private fun groups(list: List<Achievement>): List<Pair<Int, List<Achievement>>> = listOf(
|
||||
R.string.achievements_group_unlocked to list.filter { it.unlocked }.sortedByDescending { it.unlockedAt },
|
||||
R.string.achievements_group_locked to list.filter { !it.unlocked && !it.hidden },
|
||||
R.string.achievements_group_hidden to list.filter { !it.unlocked && it.hidden },
|
||||
).filter { it.second.isNotEmpty() }
|
||||
|
||||
/** Focusable so the pad can scroll the list. */
|
||||
@Composable
|
||||
internal fun AchievementRow(a: Achievement, compact: Boolean, modifier: Modifier = Modifier) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val hot = interaction.collectIsFocusedAsState().value
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
val secret = a.hidden && !a.unlocked
|
||||
val icon = if (compact) 36.dp else 48.dp
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.fillMaxWidth().clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.10f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.focusable(interactionSource = interaction)
|
||||
.padding(if (compact) 8.dp else 10.dp),
|
||||
) {
|
||||
Box(Modifier.size(icon).clip(RoundedCornerShape(8.dp)).background(pal.surfaceVariant)) {
|
||||
val url = if (a.unlocked) a.icon else a.iconLocked ?: a.icon
|
||||
if (url != null) AsyncImage(
|
||||
model = url, contentDescription = null, contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().alpha(if (a.unlocked) 1f else 0.55f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (secret) stringResource(R.string.achievements_hidden) else a.name,
|
||||
fontSize = if (compact) 14.sp else 15.sp, fontWeight = FontWeight.SemiBold,
|
||||
color = if (a.unlocked) colors.onBackground else colors.onSurfaceVariant,
|
||||
maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val desc = if (secret) stringResource(R.string.achievements_hidden_detail) else a.description
|
||||
if (desc.isNotEmpty()) Text(
|
||||
desc, fontSize = if (compact) 12.sp else 13.sp, lineHeight = if (compact) 16.sp else 18.sp,
|
||||
color = colors.onSurfaceVariant, maxLines = if (compact) 1 else 3, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (a.unlocked && a.unlockedAt > 0) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(a.unlockedAt * 1000)),
|
||||
fontSize = 12.sp, fontWeight = FontWeight.Medium, color = pal.signal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StoreAchievementsSection(game: StoreAchievements.Game) {
|
||||
val label = StoreAchievements.forSource(game.source)?.label ?: return
|
||||
StoreAchievementsSectionContent(label, rememberAchievements(game) ?: return)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun StoreAchievementsSectionContent(label: String, state: AchievementsState) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val list = state.list
|
||||
// Hidden for games without achievements.
|
||||
if (list.isNullOrEmpty() && !state.reading && state.reason != Reason.SIGNED_OUT && state.reason != Reason.UNREACHABLE) return
|
||||
val narrow = LocalNarrowPane.current
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 18.dp)) {
|
||||
Text(stringResource(R.string.achievements_title), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
achievementsNote(state, label)?.let { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
|
||||
if (!list.isNullOrEmpty()) {
|
||||
AchievementsProgress(state)
|
||||
for ((label, group) in groups(list)) {
|
||||
AchievementGroupLabel(stringResource(label, group.size))
|
||||
for (a in group) AchievementRow(a, compact = narrow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.achievementItems(list: List<Achievement>, compact: Boolean, first: FocusRequester) {
|
||||
var index = 0
|
||||
for ((label, group) in groups(list)) {
|
||||
item(key = "group-$label") { AchievementGroupLabel(stringResource(label, group.size)) }
|
||||
val offset = index
|
||||
itemsIndexed(group, key = { _, a -> "a-" + a.id }) { i, a ->
|
||||
AchievementRow(a, compact, if (offset + i == 0) Modifier.focusRequester(first) else Modifier)
|
||||
}
|
||||
index += group.size
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SessionAchievements(game: StoreAchievements.Game, title: String?, onClose: () -> Unit) {
|
||||
val label = StoreAchievements.forSource(game.source)?.label.orEmpty()
|
||||
SessionAchievementsPanel(label, rememberAchievements(game) ?: AchievementsState.READING, title, onClose)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SessionAchievementsPanel(label: String, state: AchievementsState, title: String?, onClose: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
BackHandler(onBack = onClose)
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClose),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
val short = maxHeight < 440.dp
|
||||
Column(
|
||||
Modifier.fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f))
|
||||
.background(pal.background.copy(alpha = 0.97f))
|
||||
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
|
||||
.controllerBack(onClose)
|
||||
.padding(if (short) 12.dp else 16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.achievements_title), fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
if (title != null) Text(title, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
state.list?.takeIf { it.isNotEmpty() }?.let { list ->
|
||||
if (state.all) Trophy(20.dp, Modifier.padding(end = 6.dp))
|
||||
Text(stringResource(R.string.achievements_count_short, state.done, list.size), fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
}
|
||||
}
|
||||
val list = state.list
|
||||
if (list.isNullOrEmpty()) {
|
||||
Text(achievementsNote(state, label).orEmpty(), fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 14.dp))
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = { state.done / list.size.toFloat() },
|
||||
color = pal.signal, trackColor = pal.line,
|
||||
modifier = Modifier.padding(top = if (short) 6.dp else 10.dp).fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
|
||||
)
|
||||
val first = remember { FocusRequester() }
|
||||
LazyColumn(
|
||||
Modifier.weight(1f).padding(top = 6.dp),
|
||||
contentPadding = PaddingValues(vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) { achievementItems(list, short, first) }
|
||||
LaunchedEffect(list.isNotEmpty()) {
|
||||
withFrameNanos {} // rows lay out a frame after composition
|
||||
runCatching { first.requestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [progress]: (unlocked, total), or null when unknown. */
|
||||
@Composable
|
||||
internal fun DrawerAchievementsRow(progress: Pair<Int, Int>?, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val hot = interaction.collectIsFocusedAsState().value
|
||||
val shape = RoundedCornerShape(12.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = modifier.fillMaxWidth().height(44.dp).clip(shape)
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.14f) else colors.surface)
|
||||
.glideBorder(hot, shape, pal.signal, pal.line)
|
||||
.clickable(interactionSource = interaction, indication = LocalIndication.current, onClick = onClick)
|
||||
.controllerConfirm(onClick = onClick)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
val all = progress != null && progress.second > 0 && progress.first == progress.second
|
||||
Icon(Icons.Filled.EmojiEvents, null, tint = if (all) TrophyGold else if (hot) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(R.string.achievements_title), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, modifier = Modifier.weight(1f))
|
||||
if (progress != null && progress.second > 0) {
|
||||
Text(stringResource(R.string.achievements_count_short, progress.first, progress.second), fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private val previewList = listOf(
|
||||
Achievement("first", "First Steps", "Finish the tutorial.", hidden = false, unlocked = true, unlockedAt = 1_759_000_000, icon = null, iconLocked = null),
|
||||
Achievement("boss", "Giant Slayer", "Defeat the warden of the old keep without taking damage.", hidden = false, unlocked = true, unlockedAt = 1_760_100_000, icon = null, iconLocked = null),
|
||||
Achievement("collector", "Collector", "Find every relic in the valley.", hidden = false, unlocked = false, unlockedAt = 0, icon = null, iconLocked = null),
|
||||
Achievement("secret", "The Last Door", "Open it.", hidden = true, unlocked = false, unlockedAt = 0, icon = null, iconLocked = null),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun PreviewSurface(content: @Composable () -> Unit) {
|
||||
DroidDeckTheme(theme = Themes.GRAPHITE) {
|
||||
Box(Modifier.fillMaxSize().background(LocalPalette.current.background).padding(16.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Store page section", widthDp = 800, heightDp = 600)
|
||||
@Composable
|
||||
private fun StoreAchievementsSectionPreview() = PreviewSurface {
|
||||
Column(Modifier.width(560.dp)) { StoreAchievementsSectionContent("GOG", AchievementsState(previewList, null, false)) }
|
||||
}
|
||||
|
||||
@Preview(name = "Store page section, signed out", widthDp = 800, heightDp = 200)
|
||||
@Composable
|
||||
private fun StoreAchievementsSectionSignedOutPreview() = PreviewSurface {
|
||||
Column(Modifier.width(560.dp)) { StoreAchievementsSectionContent("Epic", AchievementsState(null, Reason.SIGNED_OUT, false)) }
|
||||
}
|
||||
|
||||
@Preview(name = "Session panel", widthDp = 800, heightDp = 500)
|
||||
@Composable
|
||||
private fun SessionAchievementsPanelPreview() = DroidDeckTheme(theme = Themes.GRAPHITE) {
|
||||
SessionAchievementsPanel("Epic", AchievementsState(previewList, null, false), "Hollow Valley", onClose = {})
|
||||
}
|
||||
|
||||
@Preview(name = "Session panel, 4:3", widthDp = 560, heightDp = 420)
|
||||
@Composable
|
||||
private fun SessionAchievementsPanelShortPreview() = DroidDeckTheme(theme = Themes.GRAPHITE) {
|
||||
SessionAchievementsPanel("GOG", AchievementsState(previewList.map {
|
||||
Achievement(it.id, it.name, it.description, it.hidden, true, 1_760_000_000, null, null)
|
||||
}, null, false), "Hollow Valley", onClose = {})
|
||||
}
|
||||
|
||||
@Preview(name = "Session panel, offline", widthDp = 800, heightDp = 500)
|
||||
@Composable
|
||||
private fun SessionAchievementsPanelOfflinePreview() = DroidDeckTheme(theme = Themes.GRAPHITE) {
|
||||
SessionAchievementsPanel("GOG", AchievementsState(null, Reason.UNREACHABLE, false), "Hollow Valley", onClose = {})
|
||||
}
|
||||
|
||||
@Preview(name = "Drawer row", widthDp = 360, heightDp = 160)
|
||||
@Composable
|
||||
private fun DrawerAchievementsRowPreview() = PreviewSurface {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DrawerAchievementsRow(2 to 4) {}
|
||||
DrawerAchievementsRow(4 to 4) {}
|
||||
DrawerAchievementsRow(null) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Rows", widthDp = 480, heightDp = 300)
|
||||
@Composable
|
||||
private fun AchievementRowPreview() = PreviewSurface {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AchievementsProgress(AchievementsState(previewList, null, false))
|
||||
for (a in previewList) AchievementRow(a, compact = false)
|
||||
AchievementRow(previewList[0], compact = true)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.CatalogItem
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreAchievements
|
||||
import com.droiddeck.launcher.stores.StoresState
|
||||
import com.droiddeck.launcher.stores.download.DownloadState
|
||||
import com.droiddeck.launcher.stores.formatBytes
|
||||
@@ -128,6 +129,15 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
if (description.isNotBlank()) Rise(2) {
|
||||
Text(description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
val achievements = remember(store, id, item?.owned, installed) {
|
||||
when {
|
||||
StoreAchievements.forSource(store.id) == null -> null
|
||||
item?.owned == true -> StoreAchievements.Game.of(item)
|
||||
installed != null -> StoreAchievements.Game.of(installed.sidecar)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (achievements != null) Rise(3) { StoreAchievementsSection(achievements) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2132,4 +2132,19 @@
|
||||
<item quantity="many">%1$d ya en Juegos</item>
|
||||
<item quantity="other">%1$d ya en Juegos</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">Logros</string>
|
||||
<string name="achievements_count">%1$d de %2$d desbloqueados</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">Desbloqueados · %1$d</string>
|
||||
<string name="achievements_group_locked">Bloqueados · %1$d</string>
|
||||
<string name="achievements_group_hidden">Ocultos · %1$d</string>
|
||||
<string name="achievements_hidden">Logro oculto</string>
|
||||
<string name="achievements_hidden_detail">Los detalles se muestran al desbloquearlo.</string>
|
||||
<string name="achievements_all">Todos los logros desbloqueados</string>
|
||||
<string name="achievements_reading">Leyendo…</string>
|
||||
<string name="achievements_signed_out">Inicia sesión en %1$s para ver tus logros.</string>
|
||||
<string name="achievements_unreachable">No se pudo conectar con %1$s. Vuelve a intentarlo cuando tengas conexión.</string>
|
||||
<string name="achievements_none">%1$s no indica logros para este juego.</string>
|
||||
<string name="achievements_show_all">Ver los %1$d</string>
|
||||
<string name="achievements_dialog_title">Logros · %1$s</string>
|
||||
</resources>
|
||||
@@ -962,4 +962,19 @@
|
||||
<item quantity="one">%1$d déjà dans Jeux</item>
|
||||
<item quantity="other">%1$d déjà dans Jeux</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">Succès</string>
|
||||
<string name="achievements_count">%1$d sur %2$d débloqués</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">Débloqués · %1$d</string>
|
||||
<string name="achievements_group_locked">Verrouillés · %1$d</string>
|
||||
<string name="achievements_group_hidden">Cachés · %1$d</string>
|
||||
<string name="achievements_hidden">Succès caché</string>
|
||||
<string name="achievements_hidden_detail">Les détails s\'affichent une fois débloqué.</string>
|
||||
<string name="achievements_all">Tous les succès débloqués</string>
|
||||
<string name="achievements_reading">Lecture…</string>
|
||||
<string name="achievements_signed_out">Connectez-vous à %1$s pour voir vos succès.</string>
|
||||
<string name="achievements_unreachable">Impossible de joindre %1$s. Réessayez une fois en ligne.</string>
|
||||
<string name="achievements_none">%1$s n\'indique aucun succès pour ce jeu.</string>
|
||||
<string name="achievements_show_all">Tout afficher (%1$d)</string>
|
||||
<string name="achievements_dialog_title">Succès · %1$s</string>
|
||||
</resources>
|
||||
@@ -2088,4 +2088,19 @@
|
||||
<plurals name="games_already_n">
|
||||
<item quantity="other">%1$d 本はすでにゲームにあります</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">実績</string>
|
||||
<string name="achievements_count">%2$d 個中 %1$d 個を解除</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">解除済み · %1$d</string>
|
||||
<string name="achievements_group_locked">未解除 · %1$d</string>
|
||||
<string name="achievements_group_hidden">隠し実績 · %1$d</string>
|
||||
<string name="achievements_hidden">隠し実績</string>
|
||||
<string name="achievements_hidden_detail">解除すると詳細が表示されます。</string>
|
||||
<string name="achievements_all">すべての実績を解除</string>
|
||||
<string name="achievements_reading">読み込み中…</string>
|
||||
<string name="achievements_signed_out">実績を表示するには %1$s にサインインしてください。</string>
|
||||
<string name="achievements_unreachable">%1$s に接続できませんでした。オンラインになってから再度確認してください。</string>
|
||||
<string name="achievements_none">%1$s にはこのゲームの実績がありません。</string>
|
||||
<string name="achievements_show_all">すべて表示(%1$d)</string>
|
||||
<string name="achievements_dialog_title">実績 · %1$s</string>
|
||||
</resources>
|
||||
@@ -2088,4 +2088,19 @@
|
||||
<plurals name="games_already_n">
|
||||
<item quantity="other">%1$d개는 이미 게임에 있음</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">도전 과제</string>
|
||||
<string name="achievements_count">%2$d개 중 %1$d개 달성</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">달성 · %1$d</string>
|
||||
<string name="achievements_group_locked">미달성 · %1$d</string>
|
||||
<string name="achievements_group_hidden">숨겨짐 · %1$d</string>
|
||||
<string name="achievements_hidden">숨겨진 도전 과제</string>
|
||||
<string name="achievements_hidden_detail">달성하면 세부 정보가 표시됩니다.</string>
|
||||
<string name="achievements_all">모든 도전 과제 달성</string>
|
||||
<string name="achievements_reading">불러오는 중…</string>
|
||||
<string name="achievements_signed_out">도전 과제를 보려면 %1$s에 로그인하세요.</string>
|
||||
<string name="achievements_unreachable">%1$s에 연결할 수 없습니다. 온라인 상태에서 다시 확인하세요.</string>
|
||||
<string name="achievements_none">%1$s에 이 게임의 도전 과제가 없습니다.</string>
|
||||
<string name="achievements_show_all">모두 보기(%1$d)</string>
|
||||
<string name="achievements_dialog_title">도전 과제 · %1$s</string>
|
||||
</resources>
|
||||
@@ -1073,4 +1073,19 @@
|
||||
<item quantity="many">%1$d уже в «Играх»</item>
|
||||
<item quantity="other">%1$d уже в «Играх»</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">Достижения</string>
|
||||
<string name="achievements_count">Получено %1$d из %2$d</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">Получены · %1$d</string>
|
||||
<string name="achievements_group_locked">Не получены · %1$d</string>
|
||||
<string name="achievements_group_hidden">Скрытые · %1$d</string>
|
||||
<string name="achievements_hidden">Скрытое достижение</string>
|
||||
<string name="achievements_hidden_detail">Подробности появятся после получения.</string>
|
||||
<string name="achievements_all">Все достижения получены</string>
|
||||
<string name="achievements_reading">Загрузка…</string>
|
||||
<string name="achievements_signed_out">Войдите в %1$s, чтобы увидеть свои достижения.</string>
|
||||
<string name="achievements_unreachable">Не удалось связаться с %1$s. Проверьте снова, когда будете в сети.</string>
|
||||
<string name="achievements_none">В %1$s нет достижений для этой игры.</string>
|
||||
<string name="achievements_show_all">Показать все (%1$d)</string>
|
||||
<string name="achievements_dialog_title">Достижения · %1$s</string>
|
||||
</resources>
|
||||
@@ -2088,4 +2088,19 @@
|
||||
<plurals name="games_already_n">
|
||||
<item quantity="other">%1$d 个已在游戏中</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">成就</string>
|
||||
<string name="achievements_count">已解锁 %1$d / %2$d</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">已解锁 · %1$d</string>
|
||||
<string name="achievements_group_locked">未解锁 · %1$d</string>
|
||||
<string name="achievements_group_hidden">隐藏 · %1$d</string>
|
||||
<string name="achievements_hidden">隐藏成就</string>
|
||||
<string name="achievements_hidden_detail">解锁后显示详情。</string>
|
||||
<string name="achievements_all">已解锁全部成就</string>
|
||||
<string name="achievements_reading">读取中…</string>
|
||||
<string name="achievements_signed_out">登录 %1$s 以查看你的成就。</string>
|
||||
<string name="achievements_unreachable">无法连接到 %1$s。请在联网后再查看。</string>
|
||||
<string name="achievements_none">%1$s 未列出此游戏的成就。</string>
|
||||
<string name="achievements_show_all">显示全部 %1$d 项</string>
|
||||
<string name="achievements_dialog_title">成就 · %1$s</string>
|
||||
</resources>
|
||||
@@ -2088,4 +2088,19 @@
|
||||
<plurals name="games_already_n">
|
||||
<item quantity="other">%1$d 個已在遊戲中</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">成就</string>
|
||||
<string name="achievements_count">已解鎖 %1$d / %2$d</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">已解鎖 · %1$d</string>
|
||||
<string name="achievements_group_locked">未解鎖 · %1$d</string>
|
||||
<string name="achievements_group_hidden">隱藏 · %1$d</string>
|
||||
<string name="achievements_hidden">隱藏成就</string>
|
||||
<string name="achievements_hidden_detail">解鎖後會顯示詳情。</string>
|
||||
<string name="achievements_all">已解鎖全部成就</string>
|
||||
<string name="achievements_reading">正在讀取…</string>
|
||||
<string name="achievements_signed_out">登入 %1$s 以查看你的成就。</string>
|
||||
<string name="achievements_unreachable">無法連接 %1$s。請在連線後再查看。</string>
|
||||
<string name="achievements_none">%1$s 未列出此遊戲的成就。</string>
|
||||
<string name="achievements_show_all">顯示全部 %1$d 項</string>
|
||||
<string name="achievements_dialog_title">成就 · %1$s</string>
|
||||
</resources>
|
||||
@@ -2088,4 +2088,19 @@
|
||||
<plurals name="games_already_n">
|
||||
<item quantity="other">%1$d 個已在遊戲中</item>
|
||||
</plurals>
|
||||
<string name="achievements_title">成就</string>
|
||||
<string name="achievements_count">已解鎖 %1$d / %2$d</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">已解鎖 · %1$d</string>
|
||||
<string name="achievements_group_locked">未解鎖 · %1$d</string>
|
||||
<string name="achievements_group_hidden">隱藏 · %1$d</string>
|
||||
<string name="achievements_hidden">隱藏成就</string>
|
||||
<string name="achievements_hidden_detail">解鎖後會顯示詳細資訊。</string>
|
||||
<string name="achievements_all">已解鎖所有成就</string>
|
||||
<string name="achievements_reading">正在讀取…</string>
|
||||
<string name="achievements_signed_out">登入 %1$s 以查看你的成就。</string>
|
||||
<string name="achievements_unreachable">無法連線到 %1$s。請在連上網路後再查看。</string>
|
||||
<string name="achievements_none">%1$s 沒有列出這款遊戲的成就。</string>
|
||||
<string name="achievements_show_all">顯示全部 %1$d 項</string>
|
||||
<string name="achievements_dialog_title">成就 · %1$s</string>
|
||||
</resources>
|
||||
@@ -2125,6 +2125,22 @@
|
||||
<string name="stores_eyebrow_installed">installed</string>
|
||||
<string name="stores_eyebrow_library">in your library</string>
|
||||
<string name="stores_eyebrow_store">store</string>
|
||||
<!-- A GOG or Epic game's achievements: the store page's section and the session panel. -->
|
||||
<string name="achievements_title">Achievements</string>
|
||||
<string name="achievements_count">%1$d of %2$d unlocked</string>
|
||||
<string name="achievements_count_short">%1$d / %2$d</string>
|
||||
<string name="achievements_group_unlocked">Unlocked · %1$d</string>
|
||||
<string name="achievements_group_locked">Locked · %1$d</string>
|
||||
<string name="achievements_group_hidden">Hidden · %1$d</string>
|
||||
<string name="achievements_hidden">Hidden achievement</string>
|
||||
<string name="achievements_hidden_detail">Details are shown once it is unlocked.</string>
|
||||
<string name="achievements_all">Every achievement unlocked</string>
|
||||
<string name="achievements_reading">Reading…</string>
|
||||
<string name="achievements_signed_out">Sign in to %1$s to see your achievements.</string>
|
||||
<string name="achievements_unreachable">Could not reach %1$s. Check back once you are online.</string>
|
||||
<string name="achievements_none">%1$s lists no achievements for this game.</string>
|
||||
<string name="achievements_show_all">Show all %1$d</string>
|
||||
<string name="achievements_dialog_title">Achievements · %1$s</string>
|
||||
<string name="stores_in_steam">✓ In Steam</string>
|
||||
<string name="stores_uninstall">Uninstall</string>
|
||||
<string name="stores_uninstalling">Uninstalling…</string>
|
||||
|
||||
Reference in new issue
Block a user