diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26550ed229..c70712993a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,8 +331,8 @@ dependencies { // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-22-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-22-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-24-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-24-SNAPSHOT.jar")) implementation(libs.bundles.javasteam.dev) } else { implementation(libs.javasteam) { diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bb419edef2..0ee7d8d13e 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -10,6 +10,7 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.os.IBinder import android.util.Base64 +import app.gamenative.ui.data.Achievement import app.gamenative.ui.util.GameInviteNotificationManager import app.gamenative.ui.util.SnackbarManager import app.gamenative.service.callback.GameInviteCallback @@ -164,6 +165,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withTimeout import timber.log.Timber import app.gamenative.data.DownloadingAppInfo @@ -3090,6 +3092,57 @@ class SteamService : Service(), IChallengeUrlChanged { } } + suspend fun fetchAchievementsForDisplay(appId: Int): List? { + if (!isConnected) return null + return try { + withTimeout(15_000) { + val steamUser = instance?._steamUser ?: return@withTimeout null + val userStats = instance?._steamUserStats?.getUserStats(appId, steamUser.steamID!!)?.await() ?: return@withTimeout null + // Failed fetch (e.g. transient CM error): return null so the caller can retry. + if (userStats.result != EResult.OK) return@withTimeout null + val baseIconUrl = SteamUtils.getBaseAchievementIconUrl(appId) + val appLanguage = SteamUtils.steamLanguageForAppLocale() + val localized = userStats.getExpandedAchievements(appLanguage) + // Parse the English schema lazily: only achievements missing a localized name or + // description need it, so fully-localized games never pay for the extra parse. + val englishByName by lazy { + if (appLanguage == "english") { + emptyMap() + } else { + userStats.getExpandedAchievements("english").associateBy { it.name } + } + } + localized.map { block -> + fun english() = englishByName[block.name] + Achievement( + displayName = block.displayName?.takeIf { it.isNotBlank() } + ?: english()?.displayName?.takeIf { it.isNotBlank() } + ?: block.name ?: "", + name = block.name, + isUnlocked = block.isUnlocked, + description = block.description?.takeIf { it.isNotBlank() } + ?: english()?.description?.takeIf { it.isNotBlank() } + ?: "", + unlockTimestamp = block.unlockTimestamp, + hidden = block.hidden, + icon = if (!block.icon.isNullOrEmpty()) "$baseIconUrl${block.icon}" else "", + iconGray = if (!block.iconGray.isNullOrEmpty()) "$baseIconUrl${block.iconGray}" else null, + progressCurrent = block.progressCurrent, + progressMax = block.progressMax, + ) + } + } + } catch (e: TimeoutCancellationException) { + Timber.w("fetchAchievementsForDisplay timed out for appId=$appId") + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "fetchAchievementsForDisplay failed for appId=$appId") + null + } + } + suspend fun generateAchievements(appId: Int, configDirectory: String) { val steamUser = instance!!._steamUser!! val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await() diff --git a/app/src/main/java/app/gamenative/ui/component/InfoCard.kt b/app/src/main/java/app/gamenative/ui/component/InfoCard.kt new file mode 100644 index 0000000000..88f8100170 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/InfoCard.kt @@ -0,0 +1,139 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package app.gamenative.ui.component + +import androidx.compose.foundation.ExperimentalFoundationApi +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Labelled card showing [label] over either a [value] string or arbitrary [content]. + * [focusableForNavigation] makes it a D-pad focus stop; [onClick] makes it clickable. Either adds the [focusRing]. + */ +@Composable +fun InfoCard( + label: String, + modifier: Modifier = Modifier, + value: String? = null, + statusColor: Color? = null, + isCompact: Boolean = false, + focusableForNavigation: Boolean = false, + onClick: (() -> Unit)? = null, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + val shape = RoundedCornerShape(16.dp) + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + LaunchedEffect(isFocused) { + if (isFocused) bringIntoViewRequester.bringIntoView() + } + + val interactive = when { + // No ripple; the focusRing shows focus and a ripple would bleed past the rounded shape. + onClick != null -> Modifier.clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + focusableForNavigation -> Modifier.focusable(interactionSource = interactionSource) + else -> Modifier + } + + Surface( + modifier = modifier + .bringIntoViewRequester(bringIntoViewRequester) + .then(interactive) + .focusRing(interactionSource, shape), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 2.dp, + ) { + Column( + modifier = Modifier.padding(if (isCompact) 14.dp else 18.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Only constrain the label when the chevron shares the row; otherwise let it wrap. + val hasChevron = onClick != null + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + maxLines = if (hasChevron) 1 else Int.MAX_VALUE, + overflow = if (hasChevron) TextOverflow.Ellipsis else TextOverflow.Clip, + modifier = if (hasChevron) Modifier.weight(1f, fill = false) else Modifier, + ) + // Chevron hints the card is tappable. + if (onClick != null) { + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + Spacer(modifier = Modifier.height(6.dp)) + if (content != null) { + content() + } else if (value != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (statusColor != null) { + Box( + modifier = Modifier + .size(10.dp) + .background(statusColor, CircleShape), + ) + Spacer(modifier = Modifier.width(10.dp)) + } + Text( + text = value, + style = if (isCompact) { + MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + } else { + MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + }, + color = if (statusColor != null) statusColor else MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/app/src/main/java/app/gamenative/ui/data/Achievement.kt b/app/src/main/java/app/gamenative/ui/data/Achievement.kt new file mode 100644 index 0000000000..00f539e232 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/data/Achievement.kt @@ -0,0 +1,28 @@ +package app.gamenative.ui.data + +data class Achievement( + val displayName: String, + val name: String?, + val isUnlocked: Boolean, + val description: String, + val unlockTimestamp: Int, + val hidden: Boolean, + val icon: String, + val iconGray: String?, + val progressCurrent: Float? = null, + val progressMax: Float? = null, +){ + /** True when this achievement tracks partial progress (e.g. 45 / 100). */ + val hasProgress: Boolean + get() = progressMax != null && progressMax > 0f + + /** (date, time-of-day) of the unlock, both localized; null if never unlocked. */ + fun getFormattedUnlockDateTime(): Pair? { + if (unlockTimestamp == 0) return null + val locale = java.util.Locale.getDefault() + val millis = java.util.Date(unlockTimestamp * 1000L) + val date = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM, locale).format(millis) + val time = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT, locale).format(millis) + return date to time + } +} diff --git a/app/src/main/java/app/gamenative/ui/data/DownloadDisplayDetails.kt b/app/src/main/java/app/gamenative/ui/data/DownloadDisplayDetails.kt new file mode 100644 index 0000000000..5702e3a7d2 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/data/DownloadDisplayDetails.kt @@ -0,0 +1,15 @@ +package app.gamenative.ui.data + +/** + * Bundles the download/install flags passed to AppScreenContent. Grouping them keeps the composable's + * parameter count low enough to avoid the ART verifier rejecting the generated method (VerifyError). + */ +data class DownloadDisplayDetails( + val isInstalled: Boolean, + val isValidToDownload: Boolean, + val isDownloading: Boolean, + val downloadProgress: Float, + val hasPartialDownload: Boolean, + val isUpdatePending: Boolean, + val hasLeftoverInstall: Boolean = false, +) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 55b94093b1..b96f907caa 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -4,6 +4,30 @@ package app.gamenative.ui.screen.library import android.content.Intent import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import app.gamenative.ui.component.InfoCard +import app.gamenative.ui.component.topbar.BackButton +import app.gamenative.ui.data.Achievement import app.gamenative.ui.screen.library.components.ambient.AmbientDownloadOverlay import android.content.ActivityNotFoundException import android.net.Uri @@ -55,6 +79,7 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings @@ -67,6 +92,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -99,9 +125,13 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import app.gamenative.NetworkMonitor @@ -115,6 +145,7 @@ import app.gamenative.ui.component.GamepadButton import app.gamenative.ui.component.focusRing import app.gamenative.ui.component.LoadingScreen import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.DownloadDisplayDetails import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.internal.fakeAppInfo @@ -132,6 +163,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.math.roundToInt +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -340,80 +372,6 @@ private fun ActionIconButton( } } -/** - * Info card for game details with optional status indicator - */ -@Composable -private fun InfoCard( - label: String, - value: String, - modifier: Modifier = Modifier, - statusColor: Color? = null, - isCompact: Boolean = false, - focusableForNavigation: Boolean = false, -) { - var isFocused by remember { mutableStateOf(false) } - val interactionSource = remember { MutableInteractionSource() } - val bringIntoViewRequester = remember { BringIntoViewRequester() } - val scope = rememberCoroutineScope() - val cardModifier = if (focusableForNavigation) { - modifier - .bringIntoViewRequester(bringIntoViewRequester) - .onFocusChanged { state -> - isFocused = state.isFocused - if (state.isFocused) { - scope.launch { bringIntoViewRequester.bringIntoView() } - } - } - .focusable(interactionSource = interactionSource) - .focusRing(interactionSource, RoundedCornerShape(16.dp), width = 2.dp) - } else { - modifier - } - - Surface( - modifier = cardModifier, - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shadowElevation = 2.dp, - ) { - Column( - modifier = Modifier.padding(if (isCompact) 14.dp else 18.dp), - ) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Medium, - ) - Spacer(modifier = Modifier.height(6.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (statusColor != null) { - Box( - modifier = Modifier - .size(10.dp) - .background(statusColor, CircleShape), - ) - Spacer(modifier = Modifier.width(10.dp)) - } - Text( - text = value, - style = if (isCompact) { - MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) - } else { - MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) - }, - color = if (statusColor != null) statusColor else MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - @Composable private fun HltbInfoBar( stats: HltbService.Stats, @@ -556,22 +514,25 @@ private fun formatBytes(bytes: Long): String { internal fun AppScreenContent( modifier: Modifier = Modifier, displayInfo: GameDisplayInfo, - isInstalled: Boolean, - isValidToDownload: Boolean, - isDownloading: Boolean, - downloadProgress: Float, - hasPartialDownload: Boolean, - hasLeftoverInstall: Boolean = false, - isUpdatePending: Boolean, + downloadDisplayDetails: DownloadDisplayDetails, downloadInfo: app.gamenative.data.DownloadInfo? = null, onDownloadInstallClick: () -> Unit, onPauseResumeClick: () -> Unit, onDeleteDownloadClick: () -> Unit, onUpdateClick: () -> Unit, onBack: () -> Unit = {}, + achievements: List? = null, optionsMenu: List, dialogOpen: Boolean = false, ) { + // Unpacked so the body below is unchanged; bundling the params avoids a Compose VerifyError. + val isInstalled = downloadDisplayDetails.isInstalled + val isValidToDownload = downloadDisplayDetails.isValidToDownload + val isDownloading = downloadDisplayDetails.isDownloading + val downloadProgress = downloadDisplayDetails.downloadProgress + val hasPartialDownload = downloadDisplayDetails.hasPartialDownload + val hasLeftoverInstall = downloadDisplayDetails.hasLeftoverInstall + val isUpdatePending = downloadDisplayDetails.isUpdatePending val context = LocalContext.current // reactive — recomposes when network state changes val hasInternet by NetworkMonitor.hasInternet.collectAsState() @@ -1173,6 +1134,11 @@ internal fun AppScreenContent( } } + // Achievements + if (!achievements.isNullOrEmpty()) { + AchievementsRow(achievements = achievements) + } + } } @@ -1291,6 +1257,513 @@ fun GameMigrationDialog( ) } + +// Shared grayscale filter for locked achievement icons. +private val grayMatrix = ColorMatrix().apply { setToSaturation(0f) } + +private fun Achievement.previewIconUrl(): String? = + if (isUnlocked) icon.ifEmpty { iconGray } else iconGray ?: icon.ifEmpty { null } + +// A still-locked secret achievement, whose details Steam keeps hidden. +private val Achievement.isHiddenLocked: Boolean + get() = hidden && !isUnlocked + +// Achievement icon, grayed while locked. Pass masked = true to hide the art of a secret achievement. +@Composable +private fun AchievementIcon(ach: Achievement, size: Dp, corner: Dp, masked: Boolean = false) { + val box = Modifier + .size(size) + .clip(RoundedCornerShape(corner)) + .background(MaterialTheme.colorScheme.surfaceContainer) + if (masked) { + Box(box, contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(size / 2), + ) + } + } else { + val iconUrl = ach.previewIconUrl() + CoilImage( + imageModel = { iconUrl ?: "" }, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + contentDescription = ach.displayName ?: ach.name, + colorFilter = if (ach.isUnlocked) null else ColorFilter.colorMatrix(grayMatrix), + ), + modifier = box, + ) + } +} + +// Progress bar plus "current / max" for stat-linked achievements. +@Composable +private fun AchievementProgressBar(current: Float, max: Float, textStyle: TextStyle) { + val fraction = if (max > 0f) (current / max).coerceIn(0f, 1f) else 0f + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LinearProgressIndicator( + progress = { fraction }, + modifier = Modifier.weight(1f), + trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f), + gapSize = 0.dp, + drawStopIndicator = {}, + ) + Text( + text = "${current.toInt()} / ${max.toInt()}", + style = textStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) + } +} + +// Focusable, clickable achievement row. +@Composable +private fun AchievementRow(ach: Achievement, focusRequester: FocusRequester? = null, onClick: () -> Unit) { + val shape = RoundedCornerShape(12.dp) + val interactionSource = remember { MutableInteractionSource() } + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .focusRing(interactionSource, shape), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + AchievementIcon(ach = ach, size = 40.dp, corner = 6.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = ach.displayName ?: ach.name ?: "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!ach.description.isNullOrEmpty()) { + Text( + text = ach.description!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + val unlockedAt = ach.getFormattedUnlockDateTime() + if (ach.isUnlocked && unlockedAt != null) { + Text( + text = stringResource(R.string.achievements_unlocked_at, unlockedAt.first, unlockedAt.second), + style = MaterialTheme.typography.labelSmall, + color = PluviaTheme.colors.statusInstalled, + ) + } else if (ach.hasProgress) { + AchievementProgressBar( + current = ach.progressCurrent ?: 0f, + max = ach.progressMax ?: 0f, + textStyle = MaterialTheme.typography.labelSmall, + ) + } + } + } + } +} + +// Collapsed row standing in for still-locked secret achievements. +@Composable +private fun HiddenAchievementsSummary(count: Int, onClick: () -> Unit) { + val shape = RoundedCornerShape(12.dp) + val interactionSource = remember { MutableInteractionSource() } + Surface( + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .focusRing(interactionSource, shape), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+$count", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = pluralStringResource(R.plurals.achievements_hidden_remaining, count, count), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.achievements_hidden_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +// Full details for one achievement. +@Composable +private fun AchievementDetailDialog(ach: Achievement, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } + }, + icon = { + AchievementIcon(ach = ach, size = 64.dp, corner = 10.dp) + }, + title = { + Text( + text = ach.displayName ?: ach.name ?: "", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!ach.description.isNullOrEmpty()) { + Text( + text = ach.description!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + val unlockedAt = ach.getFormattedUnlockDateTime() + if (ach.isUnlocked && unlockedAt != null) { + Text( + text = stringResource(R.string.achievements_unlocked_at, unlockedAt.first, unlockedAt.second), + style = MaterialTheme.typography.labelMedium, + color = PluviaTheme.colors.statusInstalled, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } else if (ach.hasProgress) { + AchievementProgressBar( + current = ach.progressCurrent ?: 0f, + max = ach.progressMax ?: 0f, + textStyle = MaterialTheme.typography.labelMedium, + ) + } else { + Text( + text = stringResource(R.string.achievements_locked), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + ) +} + +@Composable +private fun AchievementsRow( + achievements: List, +) { + // Temporarily this is Steam. We can expand later for other storefronts as they become available. + val unlockedCount = achievements.count { it.isUnlocked } + val totalCount = achievements.size + var showDialog by remember { mutableStateOf(false) } + + val sortedAchievements = achievements.sortedWith( + compareByDescending { it.isUnlocked } + .thenByDescending { it.unlockTimestamp }, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + InfoCard( + label = stringResource(R.string.achievements), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 36.dp), + isCompact = true, + onClick = { showDialog = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Icons fill all space left over after the count claims its natural width. + // BoxWithConstraints then tells us exactly how many 48dp icons (+ 8dp gaps) fit. + BoxWithConstraints(modifier = Modifier.weight(1f)) { + val iconSize = 48.dp + val spacing = 8.dp + val fit = ((maxWidth + spacing) / (iconSize + spacing)) + .toInt() + .coerceIn(1, sortedAchievements.size) + val total = sortedAchievements.size + // Reserve the last slot for a "+N" stack when more achievements exist than fit. + val showStack = total > fit + val iconCount = if (showStack) (fit - 1).coerceAtLeast(0) else fit + Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { + sortedAchievements.take(iconCount).forEach { ach -> + AchievementIcon( + ach = ach, + size = iconSize, + corner = 8.dp, + masked = ach.isHiddenLocked, + ) + } + if (showStack) { + val next = sortedAchievements[iconCount] + Box( + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + if (!next.isHiddenLocked) { + val nextUrl = next.previewIconUrl() + CoilImage( + imageModel = { nextUrl ?: "" }, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + colorFilter = ColorFilter.colorMatrix(grayMatrix), + ), + modifier = Modifier.fillMaxSize(), + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.55f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+${total - iconCount}", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + } + } + } + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "$unlockedCount / $totalCount", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + // Give them a star for getting 100% completion + if (totalCount >= 1 && unlockedCount == totalCount) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringResource(R.string.achievements_complete), + tint = Color(0xFFFFD700), + modifier = Modifier.size(16.dp), + ) + } + } + Text( + text = stringResource(R.string.achievements_total), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showDialog) { + AchievementsDialog( + achievements = sortedAchievements, + onDismiss = { showDialog = false }, + ) + } +} + +@Composable +private fun AchievementsDialog( + achievements: List, + onDismiss: () -> Unit, +) { + + // Dialog destinations don't animate; fade/slide the content in and play the exit before + // dismissing, matching the screenshot gallery. + val visibleState = remember { MutableTransitionState(false).apply { targetState = true } } + LaunchedEffect(visibleState.isIdle) { + if (visibleState.isIdle && !visibleState.currentState) onDismiss() + } + val dismiss = { visibleState.targetState = false } + + // Reveal is session-only, not remembered. + var revealHidden by remember { mutableStateOf(false) } + var showRevealConfirm by remember { mutableStateOf(false) } + var detailAchievement by remember { mutableStateOf(null) } + // Keep focus on the freshly revealed achievements instead of jumping to the list top. + val revealedFocusRequester = remember { FocusRequester() } + LaunchedEffect(revealHidden) { + if (revealHidden) { + repeat(5) { + try { + if (revealedFocusRequester.requestFocus()) return@LaunchedEffect + } catch (_: IllegalStateException) { + } + delay(32) + } + } + } + + Dialog( + onDismissRequest = dismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + // Drop the window dim so the entrance animation has no scrim flash. + val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window + SideEffect { dialogWindow?.setDimAmount(0f) } + + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(200)) + + slideInVertically(animationSpec = tween(200)) { it / 12 }, + exit = fadeOut(animationSpec = tween(150)) + + slideOutVertically(animationSpec = tween(150)) { it / 12 }, + ) { + Surface( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .displayCutoutPadding() + .navigationBarsPadding(), + ) { + // Header: back + title, mirroring the screenshot gallery. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + BackButton(onClick = dismiss) + Text( + text = stringResource(R.string.achievements_all_title), + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + ), + color = MaterialTheme.colorScheme.onSurface, + ) + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Secret achievements collapse into one row until revealed. + val (hiddenLocked, visibleAchievements) = achievements.partition { it.isHiddenLocked } + items(visibleAchievements) { ach -> + AchievementRow(ach) { detailAchievement = ach } + } + if (hiddenLocked.isNotEmpty()) { + if (revealHidden) { + itemsIndexed(hiddenLocked) { index, ach -> + AchievementRow( + ach = ach, + focusRequester = if (index == 0) revealedFocusRequester else null, + ) { detailAchievement = ach } + } + } else { + item { + HiddenAchievementsSummary(count = hiddenLocked.size) { + showRevealConfirm = true + } + } + } + } + } + } + } + } + } + + if (showRevealConfirm) { + AlertDialog( + onDismissRequest = { showRevealConfirm = false }, + title = { Text(stringResource(R.string.achievements_reveal_title)) }, + text = { Text(stringResource(R.string.achievements_reveal_message)) }, + confirmButton = { + TextButton(onClick = { + revealHidden = true + showRevealConfirm = false + }) { Text(stringResource(R.string.achievements_reveal_confirm)) } + }, + dismissButton = { + TextButton(onClick = { showRevealConfirm = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + detailAchievement?.let { ach -> + AchievementDetailDialog(ach) { detailAchievement = null } + } +} + + /*********** * PREVIEW * ***********/ @@ -1326,12 +1799,14 @@ private fun Preview_AppScreen() { Surface { AppScreenContent( displayInfo = displayInfo, - isInstalled = false, - isValidToDownload = true, - isDownloading = isDownloading, - downloadProgress = .50f, - hasPartialDownload = false, - isUpdatePending = false, + downloadDisplayDetails = DownloadDisplayDetails( + isInstalled = false, + isValidToDownload = true, + isDownloading = isDownloading, + downloadProgress = .50f, + hasPartialDownload = false, + isUpdatePending = false, + ), downloadInfo = null, onDownloadInstallClick = { isDownloading = !isDownloading }, onPauseResumeClick = { }, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 03d6f40f59..4f1b51ccab 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -34,6 +34,7 @@ import app.gamenative.mods.NexusModManager import app.gamenative.ui.component.dialog.ContainerConfigDialog import app.gamenative.ui.component.dialog.NexusModsDialog import app.gamenative.ui.data.AppMenuOption +import app.gamenative.ui.data.Achievement import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType import app.gamenative.ui.util.ContainerConfigTransfer @@ -1044,6 +1045,9 @@ abstract class BaseAppScreen { var hasLeftoverInstallState by remember(libraryItem.appId) { mutableStateOf(hasLeftoverInstall(context, libraryItem)) } + var achievementsState by remember(libraryItem.appId) { + mutableStateOf?>(null) + } val uiScope = rememberCoroutineScope() @@ -1070,6 +1074,30 @@ abstract class BaseAppScreen { performStateRefresh(true) } + LaunchedEffect(libraryItem.appId) { + if (getGameSource(libraryItem) == GameSource.STEAM) { + // null = fetch failed (an empty list means the game has no achievements); retry a + // few times so a transient Steam error doesn't silently drop the section. + repeat(3) { attempt -> + val result = try { + withContext(Dispatchers.IO) { + app.gamenative.service.SteamService.fetchAchievementsForDisplay(getGameId(libraryItem)) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to fetch achievements for ${getGameId(libraryItem)}") + null + } + if (result != null) { + achievementsState = result + return@LaunchedEffect + } + if (attempt < 2) delay(2000) + } + } + } + var showConfigDialog by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } @@ -1325,13 +1353,15 @@ abstract class BaseAppScreen { // Render the common UI app.gamenative.ui.screen.library.AppScreenContent( displayInfo = displayInfo, - isInstalled = isInstalledState, - isValidToDownload = isValidToDownloadState, - isDownloading = isDownloadingState, - downloadProgress = downloadProgressState, - hasPartialDownload = hasPartialDownloadState, - hasLeftoverInstall = hasLeftoverInstallState, - isUpdatePending = isUpdatePendingState, + downloadDisplayDetails = app.gamenative.ui.data.DownloadDisplayDetails( + isInstalled = isInstalledState, + isValidToDownload = isValidToDownloadState, + isDownloading = isDownloadingState, + downloadProgress = downloadProgressState, + hasPartialDownload = hasPartialDownloadState, + hasLeftoverInstall = hasLeftoverInstallState, + isUpdatePending = isUpdatePendingState, + ), downloadInfo = downloadInfo, onDownloadInstallClick = { if (app.gamenative.launch.LaunchReadiness.pending) { @@ -1358,6 +1388,7 @@ abstract class BaseAppScreen { } }, onBack = onBack, + achievements = achievementsState, optionsMenu = optionsMenu, dialogOpen = showConfigDialog || manageModsRequested, ) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 3955d16d4c..065847c08d 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -145,6 +145,32 @@ object SteamUtils { } } + fun getBaseAchievementIconUrl(appId: Int): String = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/$appId/" + + /** + * Steam achievement-schema language name for the app's current UI locale. Steam's names are the + * lowercase English name of the language (german, french, ukrainian, romanian, …) apart from a + * few proprietary ones, so we special-case those and derive the rest. A name the schema doesn't + * carry falls back to English per-achievement when it is read. + */ + fun steamLanguageForAppLocale(locale: Locale = Locale.getDefault()): String { + return when (locale.language) { + "ko" -> "koreana" + // Steam splits Spanish into Castilian ("spanish") and Latin American ("latam"). + "es" -> if (locale.country.isNotEmpty() && !locale.country.equals("ES", true)) "latam" else "spanish" + "pt" -> if (locale.country.equals("BR", true)) "brazilian" else "portuguese" + "zh" -> if (locale.country.equals("TW", true) || locale.country.equals("HK", true) || + locale.country.equals("MO", true) || locale.script.equals("Hant", true) + ) { + "tchinese" + } else { + "schinese" + } + // substringBefore drops variant suffixes like "Norwegian Bokmål" -> "norwegian". + else -> locale.getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ENGLISH).substringBefore(' ') + } + } + internal val http = Net.http.newBuilder() .readTimeout(5, TimeUnit.MINUTES) .callTimeout(0, TimeUnit.MILLISECONDS) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c14c321928..09bc6b5168 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1956,6 +1956,21 @@ Tilgængelig nu Anbefalet Tilføj eller spil nogle spil for at få GOG-anbefalinger baseret på dit bibliotek. + + Præstationer + Alle præstationer + Låst op den %1$s kl. %2$s + Alle præstationer låst op + I alt + + %1$d skjult præstation tilbage + %1$d skjulte præstationer tilbage + + Detaljerne for hver præstation afsløres, når den låses op + Afslør skjulte præstationer? + Dette viser de resterende hemmelige præstationer og deres detaljer nu. Det huskes ikke. + Afslør + Låst En gemt samling blev fjernet i Steam og filtreres ikke længere. Steam-samlinger Indlæser samlinger… diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aea4a276b8..bc9e670b25 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2026,6 +2026,21 @@ Jetzt verfügbar Empfohlen Füge Spiele hinzu oder spiele welche, um GOG-Empfehlungen basierend auf deiner Bibliothek zu erhalten. + + Erfolge + Alle Erfolge + Freigeschaltet am %1$s um %2$s + Alle Erfolge freigeschaltet + Gesamt + + %1$d verborgene Errungenschaft übrig + %1$d verborgene Errungenschaften übrig + + Die Details jeder Errungenschaft werden nach dem Freischalten angezeigt + Verborgene Errungenschaften anzeigen? + Dies zeigt vorübergehend die verbleibenden geheimen Errungenschaften und ihre Details an. Es wird nicht gespeichert. + Anzeigen + Gesperrt Eine gespeicherte Sammlung wurde in Steam entfernt und wird nicht mehr gefiltert. Steam-Sammlungen Sammlungen werden geladen… diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 182c198d43..e49141a967 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2084,6 +2084,21 @@ Disponible ahora Recomendados Añade o juega a algunos juegos para recibir recomendaciones de GOG basadas en tu biblioteca. + + Logros + Todos los logros + Desbloqueado el %1$s a las %2$s + Todos los logros desbloqueados + Total + + %1$d logro oculto restante + %1$d logros ocultos restantes + + Los detalles de cada logro se revelarán al desbloquearlo + ¿Mostrar logros ocultos? + Esto muestra por ahora los logros secretos restantes y sus detalles. No se recuerda. + Mostrar + Bloqueado Se eliminó una colección guardada en Steam y ya no se filtra. Colecciones de Steam Cargando colecciones… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 670499510e..92fe99195c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2086,6 +2086,21 @@ Disponible maintenant Recommandés Ajoutez ou lancez des jeux pour obtenir des recommandations GOG basées sur votre bibliothèque. + + Succès + Tous les succès + Débloqué le %1$s à %2$s + Tous les succès débloqués + Total + + %1$d succès caché restant + %1$d succès cachés restants + + Les détails de chaque succès seront révélés une fois débloqués + Révéler les succès cachés ? + Affiche pour le moment les succès cachés restants et leurs détails. Ce choix ne sera pas mémorisé. + Révéler + Verrouillé Une collection enregistrée a été supprimée dans Steam et n\'est plus filtrée. Collections Steam Chargement des collections… diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index f3a09d866c..875c2c40e0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2077,6 +2077,21 @@ Ora disponibile Consigliati Aggiungi o gioca ad alcuni giochi per ricevere consigli GOG basati sulla tua libreria. + + Obiettivi + Tutti gli obiettivi + Sbloccato il %1$s alle %2$s + Tutti i traguardi sbloccati + Totale + + %1$d obiettivo nascosto rimanente + %1$d obiettivi nascosti rimanenti + + I dettagli di ogni obiettivo verranno rivelati una volta sbloccati + Mostrare gli obiettivi nascosti? + Mostra temporaneamente gli obiettivi segreti rimanenti e i loro dettagli. Non verrà memorizzato. + Mostra + Bloccato Una raccolta salvata è stata rimossa da Steam e non viene più filtrata. Collezioni Steam Caricamento delle raccolte… diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d0e920862a..188c5d7a09 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2043,6 +2043,20 @@ 現在利用可能 おすすめ ゲームを追加またはプレイすると、ライブラリに基づいたGOGのおすすめが表示されます。 + + 実績 + すべての実績 + %1$s %2$s に解除 + すべての実績を解除しました + 合計 + + 隠し実績 残り%1$d個 + + 各実績の詳細は解除すると表示されます + 隠し実績を表示しますか? + 残りの隠し実績とその詳細を一時的に表示します。記憶されません。 + 表示 + 未解除 保存されたコレクションが Steam で削除されたため、フィルターから除外されました。 Steam コレクション コレクションを読み込み中… diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b98b611531..ff491c219f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2084,6 +2084,20 @@ 지금 이용 가능 추천 게임을 추가하거나 플레이하면 라이브러리를 기반으로 한 GOG 추천을 받을 수 있습니다. + + 업적 + 모든 업적 + %1$s %2$s에 달성 + 모든 업적 달성 + 총계 + + 숨겨진 도전 과제 %1$d개 남음 + + 각 도전 과제의 세부 정보는 잠금 해제 시 공개됩니다 + 숨겨진 도전 과제를 표시할까요? + 남은 비밀 도전 과제와 세부 정보를 지금만 표시합니다. 기억되지 않습니다. + 표시 + 잠김 저장된 컬렉션이 Steam에서 제거되어 더 이상 필터링되지 않습니다. Steam 컬렉션 컬렉션 불러오는 중… diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d3a99bacb2..5cb9ab8bfa 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2084,6 +2084,23 @@ Dostępne teraz Polecane Dodaj lub zagraj w kilka gier, aby otrzymać rekomendacje GOG na podstawie Twojej biblioteki. + + Osiągnięcia + Wszystkie osiągnięcia + Odblokowano %1$s o %2$s + Wszystkie osiągnięcia odblokowane + Razem + + Pozostało %1$d ukryte osiągnięcie + Pozostały %1$d ukryte osiągnięcia + Pozostało %1$d ukrytych osiągnięć + Pozostało %1$d ukrytych osiągnięć + + Szczegóły każdego osiągnięcia zostaną ujawnione po odblokowaniu + Pokazać ukryte osiągnięcia? + Tymczasowo pokazuje pozostałe sekretne osiągnięcia i ich szczegóły. Nie zostanie zapamiętane. + Pokaż + Zablokowane Zapisana kolekcja została usunięta w Steam i nie jest już filtrowana. Kolekcje Steam Wczytywanie kolekcji… diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 66c4e4291f..ccabbbef7d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1956,6 +1956,21 @@ Disponível agora Recomendados Adicione ou jogue alguns jogos para receber recomendações da GOG com base na sua biblioteca. + + Conquistas + Todas as conquistas + Desbloqueado em %1$s às %2$s + Todas as conquistas desbloqueadas + Total + + %1$d conquista oculta restante + %1$d conquistas ocultas restantes + + Os detalhes de cada conquista serão revelados após o desbloqueio + Revelar conquistas ocultas? + Mostra por enquanto as conquistas secretas restantes e seus detalhes. Não é lembrado. + Revelar + Bloqueado Uma coleção salva foi removida no Steam e não é mais filtrada. Coleções da Steam Carregando coleções… diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ec513ec4f0..a343437bcc 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2087,6 +2087,22 @@ Acum disponibil Recomandate Adaugă sau joacă câteva jocuri pentru a primi recomandări GOG pe baza bibliotecii tale. + + Realizări + Toate realizările + Deblocat pe %1$s la %2$s + Toate realizările deblocate + Total + + %1$d realizare ascunsă rămasă + %1$d realizări ascunse rămase + %1$d de realizări ascunse rămase + + Detaliile fiecărei realizări vor fi dezvăluite după deblocare + Afișezi realizările ascunse? + Afișează temporar realizările secrete rămase și detaliile lor. Nu este memorat. + Afișează + Blocat O colecție salvată a fost eliminată în Steam și nu mai este filtrată. Colecții Steam Se încarcă colecțiile… diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7154645ffb..bfdb46680c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2012,6 +2012,23 @@ https://gamenative.app Доступно сейчас Рекомендации Добавьте или поиграйте в игры, чтобы получить рекомендации GOG на основе вашей библиотеки. + + Достижения + Все достижения + Разблокировано %1$s в %2$s + Все достижения разблокированы + Всего + + Осталось %1$d скрытое достижение + Осталось %1$d скрытых достижения + Осталось %1$d скрытых достижений + Осталось %1$d скрытых достижений + + Подробности каждого достижения будут раскрыты после разблокировки + Показать скрытые достижения? + Временно показывает оставшиеся секретные достижения и их детали. Это не запоминается. + Показать + Заблокировано Сохранённая коллекция была удалена в Steam и больше не используется для фильтрации. Коллекции Steam Загрузка коллекций… diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 84b2bb645c..cf0325c190 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2080,6 +2080,23 @@ Доступно зараз Рекомендовані Додайте або пограйте в ігри, щоб отримати рекомендації GOG на основі вашої бібліотеки. + + Досягнення + Всі досягнення + Розблоковано %1$s о %2$s + Усі досягнення розблоковано + Усього + + Залишилося %1$d приховане досягнення + Залишилося %1$d приховані досягнення + Залишилося %1$d прихованих досягнень + Залишилося %1$d прихованих досягнень + + Деталі кожного досягнення буде розкрито після розблокування + Показати приховані досягнення? + Тимчасово показує решту секретних досягнень та їхні деталі. Це не запам’ятовується. + Показати + Заблоковано Збережену колекцію було видалено в Steam, і вона більше не фільтрується. Колекції Steam Завантаження колекцій… diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e0c2315787..fd8ebb9fe9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2104,6 +2104,20 @@ 现在可用 推荐 添加或试玩一些游戏,即可根据你的游戏库获得 GOG 推荐。 + + 成就 + 所有成就 + %1$s %2$s 解锁 + 所有成就已解锁 + 总数 + + 还有 %1$d 个隐藏成就 + + 每个成就的详情将在解锁后揭晓 + 显示隐藏成就? + 暂时显示剩余的隐藏成就及其详情,不会被记住。 + 显示 + 未解锁 已保存的收藏集已在 Steam 中移除,不再用于筛选。 Steam 收藏集 正在加载收藏集… diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1fcafe46eb..9603eec642 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2095,6 +2095,20 @@ 現在可用 推薦 新增或試玩一些遊戲,即可根據你的遊戲庫獲得 GOG 推薦。 + + 成就 + 所有成就 + %1$s %2$s 解鎖 + 所有成就已解鎖 + 總數 + + 還有 %1$d 個隱藏成就 + + 每個成就的詳細資訊將在解鎖後揭曉 + 顯示隱藏成就? + 暫時顯示剩餘的隱藏成就及其詳情,不會被記住。 + 顯示 + 未解鎖 已儲存的收藏已在 Steam 中移除,不再用於篩選。 Steam 收藏 正在載入收藏… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index defb0230ff..58d6941925 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2175,4 +2175,20 @@ Copied %1$s of %2$s This managed mod has incomplete source information and cannot be retried. Archive needs more memory than Android allows. Retry after updating GameNative or choose a smaller file. + + + Achievements + All Achievements + Unlocked on %1$s at %2$s + All achievements unlocked + Total + + %1$d hidden achievement remaining + %1$d hidden achievements remaining + + The details of each achievement will be revealed once unlocked + Reveal hidden achievements? + This shows the remaining secret achievements and their details for now. It is not remembered. + Reveal + Locked diff --git a/app/src/test/java/app/gamenative/utils/SteamUtilsLanguageTest.kt b/app/src/test/java/app/gamenative/utils/SteamUtilsLanguageTest.kt new file mode 100644 index 0000000000..0b76375b76 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/SteamUtilsLanguageTest.kt @@ -0,0 +1,52 @@ +package app.gamenative.utils + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Locale + +class SteamUtilsLanguageTest { + + private fun lang(language: String, country: String = "") = + SteamUtils.steamLanguageForAppLocale(Locale(language, country)) + + @Test + fun mapsSteamSpecificLanguages() { + assertEquals("koreana", lang("ko")) + assertEquals("brazilian", lang("pt", "BR")) + assertEquals("portuguese", lang("pt", "PT")) + assertEquals("portuguese", lang("pt")) + } + + @Test + fun splitsSpanishByRegion() { + assertEquals("spanish", lang("es", "ES")) + assertEquals("spanish", lang("es")) + assertEquals("latam", lang("es", "MX")) + assertEquals("latam", lang("es", "AR")) + } + + @Test + fun splitsChineseByRegionAndScript() { + assertEquals("schinese", lang("zh", "CN")) + assertEquals("schinese", lang("zh")) + assertEquals("tchinese", lang("zh", "TW")) + assertEquals("tchinese", lang("zh", "HK")) + assertEquals("tchinese", lang("zh", "MO")) + assertEquals( + "tchinese", + SteamUtils.steamLanguageForAppLocale(Locale.Builder().setLanguage("zh").setScript("Hant").build()), + ) + } + + @Test + fun fallsBackToEnglishDisplayName() { + assertEquals("english", lang("en")) + assertEquals("french", lang("fr")) + assertEquals("german", lang("de")) + assertEquals("italian", lang("it")) + assertEquals("japanese", lang("ja")) + assertEquals("russian", lang("ru")) + assertEquals("polish", lang("pl")) + assertEquals("ukrainian", lang("uk")) + } +}