From 5e7ce25823e66ba23ec49c5a9b7f182f4f0cfe93 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 13 Jul 2026 01:23:09 +0200 Subject: [PATCH 01/12] Add Steam achievements viewer --- .../app/gamenative/service/SteamService.kt | 49 +++ .../app/gamenative/ui/component/InfoCard.kt | 139 ++++++ .../app/gamenative/ui/data/Achievement.kt | 22 + .../ui/data/DownloadDisplayDetails.kt | 15 + .../ui/screen/library/LibraryAppScreen.kt | 408 ++++++++++++++---- .../screen/library/appscreen/BaseAppScreen.kt | 45 +- .../java/app/gamenative/utils/SteamUtils.kt | 27 ++ app/src/main/res/values-da/strings.xml | 6 + app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-ja/strings.xml | 6 + app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-pl/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values-ro/strings.xml | 6 + app/src/main/res/values-ru/strings.xml | 6 + app/src/main/res/values-uk/strings.xml | 6 + app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values-zh-rTW/strings.xml | 6 + app/src/main/res/values/strings.xml | 7 + 22 files changed, 702 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/component/InfoCard.kt create mode 100644 app/src/main/java/app/gamenative/ui/data/Achievement.kt create mode 100644 app/src/main/java/app/gamenative/ui/data/DownloadDisplayDetails.kt diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 9d30ac58de..82a46fdedb 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.SnackbarManager import androidx.room.withTransaction import app.gamenative.BuildConfig @@ -156,6 +157,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 @@ -3080,6 +3082,53 @@ 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 + 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, + ) + } + } + } 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..f27e39ec85 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/data/Achievement.kt @@ -0,0 +1,22 @@ +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? +){ + /** (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 0e74b9f4ca..1701e0701a 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 android.annotation.SuppressLint +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.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 @@ -115,6 +139,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 @@ -340,80 +365,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,21 +507,24 @@ 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, ) { + // 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() @@ -1160,6 +1114,11 @@ internal fun AppScreenContent( } } + // Achievements + if (!achievements.isNullOrEmpty()) { + AchievementsRow(achievements = achievements) + } + } } @@ -1278,6 +1237,279 @@ 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 } + +@SuppressLint("UnusedBoxWithConstraintsScope") +@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 -> + 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 = Modifier + .size(iconSize) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } + if (showStack) { + val next = sortedAchievements[iconCount] + val nextUrl = next.previewIconUrl() + Box( + modifier = Modifier + .size(iconSize) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center, + ) { + 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 } + + 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), + ) { + items(achievements) { ach -> + val iconUrl = ach.previewIconUrl() + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CoilImage( + imageModel = { iconUrl ?: "" }, + imageOptions = ImageOptions( + contentScale = ContentScale.Crop, + contentDescription = ach.displayName ?: ach.name, + colorFilter = if (ach.isUnlocked) null else ColorFilter.colorMatrix(grayMatrix), + ), + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + 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, + ) + } + } + } + } + } + } + } + } + } + } +} + + /*********** * PREVIEW * ***********/ @@ -1313,12 +1545,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 b7e056882c..bf836788ab 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, ) diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 64d169e1e8..06973b9dd7 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -142,6 +142,33 @@ 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(): String { + val locale = Locale.getDefault() + 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 96be80556e..974eebb165 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1948,4 +1948,10 @@ 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 72a3e42b3d..6b8e30b8d3 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2018,4 +2018,10 @@ 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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b065cd0964..d9423b5a7b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2076,4 +2076,10 @@ 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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ab04687bef..a766b9cc04 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2078,4 +2078,10 @@ 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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a58a0a2d13..d28ef8f8d4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2069,4 +2069,10 @@ 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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 013dfae7d8..cd5ce67ee0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2035,4 +2035,10 @@ 現在利用可能 おすすめ ゲームを追加またはプレイすると、ライブラリに基づいたGOGのおすすめが表示されます。 + + 実績 + すべての実績 + %1$s %2$s に解除 + すべての実績を解除しました + 合計 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e87772950e..9f9d9b98a4 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2076,4 +2076,10 @@ 지금 이용 가능 추천 게임을 추가하거나 플레이하면 라이브러리를 기반으로 한 GOG 추천을 받을 수 있습니다. + + 업적 + 모든 업적 + %1$s %2$s에 달성 + 모든 업적 달성 + 총계 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 54164cb478..4f32e974b9 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2076,4 +2076,10 @@ 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 71c7e5ea61..7b14c15aed 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1948,4 +1948,10 @@ 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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c05d059440..a55c300988 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2079,4 +2079,10 @@ 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d711734ed7..26c880179f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2004,4 +2004,10 @@ https://gamenative.app Доступно сейчас Рекомендации Добавьте или поиграйте в игры, чтобы получить рекомендации GOG на основе вашей библиотеки. + + Достижения + Все достижения + Разблокировано %1$s в %2$s + Все достижения разблокированы + Всего diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a54443c0ff..1aedf280a0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2072,4 +2072,10 @@ Доступно зараз Рекомендовані Додайте або пограйте в ігри, щоб отримати рекомендації GOG на основі вашої бібліотеки. + + Досягнення + Всі досягнення + Розблоковано %1$s о %2$s + Усі досягнення розблоковано + Усього diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c68407fe10..69a2736bbd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2096,4 +2096,10 @@ 现在可用 推荐 添加或试玩一些游戏,即可根据你的游戏库获得 GOG 推荐。 + + 成就 + 所有成就 + %1$s %2$s 解锁 + 所有成就已解锁 + 总数 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c9d7cd17d8..764554f42a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2087,4 +2087,10 @@ 現在可用 推薦 新增或試玩一些遊戲,即可根據你的遊戲庫獲得 GOG 推薦。 + + 成就 + 所有成就 + %1$s %2$s 解鎖 + 所有成就已解鎖 + 總數 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d416a16540..e35fcd9b10 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2076,4 +2076,11 @@ Tip: If you have a Mali GPU, please use System Drivers. Tip: Getting a blank screen? Try using the \"%s\" option in the menu to check if your drivers are working correctly. Tip: Use Proton x86-64 if you can\'t click the mouse in games. + + + Achievements + All Achievements + Unlocked on %1$s at %2$s + All achievements unlocked + Total From 5fab9f7e32e41c4772ca8d9669a2a9d586f18dc9 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 13 Jul 2026 13:49:02 +0200 Subject: [PATCH 02/12] Show achievement progress bars in the achievements dialog --- .../app/gamenative/service/SteamService.kt | 2 ++ .../app/gamenative/ui/data/Achievement.kt | 8 ++++++- .../ui/screen/library/LibraryAppScreen.kt | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 82a46fdedb..9efcfd687b 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -3115,6 +3115,8 @@ class SteamService : Service(), IChallengeUrlChanged { 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, ) } } diff --git a/app/src/main/java/app/gamenative/ui/data/Achievement.kt b/app/src/main/java/app/gamenative/ui/data/Achievement.kt index f27e39ec85..00f539e232 100644 --- a/app/src/main/java/app/gamenative/ui/data/Achievement.kt +++ b/app/src/main/java/app/gamenative/ui/data/Achievement.kt @@ -8,8 +8,14 @@ data class Achievement( val unlockTimestamp: Int, val hidden: Boolean, val icon: String, - val iconGray: 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 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 1701e0701a..f4e8c791b4 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 @@ -1497,6 +1497,28 @@ private fun AchievementsDialog( style = MaterialTheme.typography.labelSmall, color = PluviaTheme.colors.statusInstalled, ) + } else if (ach.hasProgress) { + val current = ach.progressCurrent ?: 0f + val max = ach.progressMax ?: 1f + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LinearProgressIndicator( + progress = { (current / max).coerceIn(0f, 1f) }, + modifier = Modifier.weight(1f), + trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f), + gapSize = 0.dp, + drawStopIndicator = {}, + ) + Text( + text = "${current.toInt()} / ${max.toInt()}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + ) + } } } } From 2dab4675d4c2cf62cca22895d652f6fa3d47e1a1 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 13 Jul 2026 16:55:22 +0200 Subject: [PATCH 03/12] Bump JavaSteam to 1.8.0.1-23-SNAPSHOT for achievement progress --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb7b47e97b..faad503adc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-21-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest +javasteam = "1.8.0.1-23-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit From f8e6d5423b1cf682c5639588dc03cb632c0275a4 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 13 Jul 2026 16:55:22 +0200 Subject: [PATCH 04/12] Collapse secret achievements and add achievement detail dialog --- .../ui/screen/library/LibraryAppScreen.kt | 410 ++++++++++++++---- app/src/main/res/values-da/strings.xml | 6 + app/src/main/res/values-de/strings.xml | 6 + app/src/main/res/values-es/strings.xml | 6 + app/src/main/res/values-fr/strings.xml | 6 + app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values-ja/strings.xml | 6 + app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-pl/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values-ro/strings.xml | 6 + app/src/main/res/values-ru/strings.xml | 6 + app/src/main/res/values-uk/strings.xml | 6 + app/src/main/res/values-zh-rCN/strings.xml | 6 + app/src/main/res/values-zh-rTW/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 16 files changed, 404 insertions(+), 96 deletions(-) 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 f4e8c791b4..7626bd5e22 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,7 +4,6 @@ package app.gamenative.ui.screen.library import android.content.Intent import android.content.res.Configuration -import android.annotation.SuppressLint import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -16,6 +15,7 @@ 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 @@ -79,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 @@ -91,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 @@ -125,7 +127,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource 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 @@ -157,6 +162,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 @@ -1244,7 +1250,240 @@ private val grayMatrix = ColorMatrix().apply { setToSaturation(0f) } private fun Achievement.previewIconUrl(): String? = if (isUnlocked) icon.ifEmpty { iconGray } else iconGray ?: icon.ifEmpty { null } -@SuppressLint("UnusedBoxWithConstraintsScope") +// 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 = stringResource(R.string.achievements_hidden_remaining, 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, @@ -1287,23 +1526,15 @@ private fun AchievementsRow( val iconCount = if (showStack) (fit - 1).coerceAtLeast(0) else fit Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { sortedAchievements.take(iconCount).forEach { ach -> - 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 = Modifier - .size(iconSize) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer), + AchievementIcon( + ach = ach, + size = iconSize, + corner = 8.dp, + masked = ach.isHiddenLocked, ) } if (showStack) { val next = sortedAchievements[iconCount] - val nextUrl = next.previewIconUrl() Box( modifier = Modifier .size(iconSize) @@ -1311,14 +1542,17 @@ private fun AchievementsRow( .background(MaterialTheme.colorScheme.surfaceContainer), contentAlignment = Alignment.Center, ) { - CoilImage( - imageModel = { nextUrl ?: "" }, - imageOptions = ImageOptions( - contentScale = ContentScale.Crop, - colorFilter = ColorFilter.colorMatrix(grayMatrix), - ), - modifier = Modifier.fillMaxSize(), - ) + 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() @@ -1392,6 +1626,24 @@ private fun AchievementsDialog( } 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( @@ -1447,79 +1699,23 @@ private fun AchievementsDialog( contentPadding = PaddingValues(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(achievements) { ach -> - val iconUrl = ach.previewIconUrl() - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - CoilImage( - imageModel = { iconUrl ?: "" }, - imageOptions = ImageOptions( - contentScale = ContentScale.Crop, - contentDescription = ach.displayName ?: ach.name, - colorFilter = if (ach.isUnlocked) null else ColorFilter.colorMatrix(grayMatrix), - ), - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) - 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) { - val current = ach.progressCurrent ?: 0f - val max = ach.progressMax ?: 1f - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - LinearProgressIndicator( - progress = { (current / max).coerceIn(0f, 1f) }, - modifier = Modifier.weight(1f), - trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f), - gapSize = 0.dp, - drawStopIndicator = {}, - ) - Text( - text = "${current.toInt()} / ${max.toInt()}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - softWrap = false, - ) - } - } + // 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 } } } @@ -1529,6 +1725,28 @@ private fun AchievementsDialog( } } } + + 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 } + } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 974eebb165..897a1a2498 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1954,4 +1954,10 @@ Låst op den %1$s kl. %2$s Alle præstationer låst op I alt + %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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6b8e30b8d3..e1f1a2e711 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2024,4 +2024,10 @@ Freigeschaltet am %1$s um %2$s Alle Erfolge freigeschaltet Gesamt + %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 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d9423b5a7b..50f2126b43 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2082,4 +2082,10 @@ Desbloqueado el %1$s a las %2$s Todos los logros desbloqueados Total + %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 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a766b9cc04..a6d3dcb60a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2084,4 +2084,10 @@ Débloqué le %1$s à %2$s Tous les succès débloqués Total + %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é diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d28ef8f8d4..dad26f8be0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2075,4 +2075,10 @@ Sbloccato il %1$s alle %2$s Tutti i traguardi sbloccati Totale + %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 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index cd5ce67ee0..21b248b2d9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2041,4 +2041,10 @@ %1$s %2$s に解除 すべての実績を解除しました 合計 + 隠し実績 残り%1$d個 + 各実績の詳細は解除すると表示されます + 隠し実績を表示しますか? + 残りの隠し実績とその詳細を一時的に表示します。記憶されません。 + 表示 + 未解除 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9f9d9b98a4..b256f140b9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2082,4 +2082,10 @@ %1$s %2$s에 달성 모든 업적 달성 총계 + 숨겨진 도전 과제 %1$d개 남음 + 각 도전 과제의 세부 정보는 잠금 해제 시 공개됩니다 + 숨겨진 도전 과제를 표시할까요? + 남은 비밀 도전 과제와 세부 정보를 지금만 표시합니다. 기억되지 않습니다. + 표시 + 잠김 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4f32e974b9..53c9b443c8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2082,4 +2082,10 @@ Odblokowano %1$s o %2$s Wszystkie osiągnięcia odblokowane Razem + Pozostało ukrytych osiągnięć: %1$d + 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 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7b14c15aed..bf8ecdb6df 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1954,4 +1954,10 @@ Desbloqueado em %1$s às %2$s Todas as conquistas desbloqueadas Total + %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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a55c300988..31dcef2cbc 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2085,4 +2085,10 @@ Deblocat pe %1$s la %2$s Toate realizările deblocate Total + %1$d 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 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 26c880179f..6a59534969 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2010,4 +2010,10 @@ https://gamenative.app Разблокировано %1$s в %2$s Все достижения разблокированы Всего + Осталось скрытых достижений: %1$d + Подробности каждого достижения будут раскрыты после разблокировки + Показать скрытые достижения? + Временно показывает оставшиеся секретные достижения и их детали. Это не запоминается. + Показать + Заблокировано diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1aedf280a0..ff68537b23 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2078,4 +2078,10 @@ Розблоковано %1$s о %2$s Усі досягнення розблоковано Усього + Залишилось прихованих досягнень: %1$d + Деталі кожного досягнення буде розкрито після розблокування + Показати приховані досягнення? + Тимчасово показує решту секретних досягнень та їхні деталі. Це не запам’ятовується. + Показати + Заблоковано diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 69a2736bbd..f26212b475 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2102,4 +2102,10 @@ %1$s %2$s 解锁 所有成就已解锁 总数 + 还有 %1$d 个隐藏成就 + 每个成就的详情将在解锁后揭晓 + 显示隐藏成就? + 暂时显示剩余的隐藏成就及其详情,不会被记住。 + 显示 + 未解锁 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 764554f42a..4e1746dc07 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2093,4 +2093,10 @@ %1$s %2$s 解鎖 所有成就已解鎖 總數 + 還有 %1$d 個隱藏成就 + 每個成就的詳細資訊將在解鎖後揭曉 + 顯示隱藏成就? + 暫時顯示剩餘的隱藏成就及其詳情,不會被記住。 + 顯示 + 未解鎖 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e35fcd9b10..0c488dddd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2083,4 +2083,10 @@ Unlocked on %1$s at %2$s All achievements unlocked Total + %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 From 99caba39cf333556235273850ef722518d540a8d Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 13 Jul 2026 17:34:06 +0200 Subject: [PATCH 05/12] Address PR feedback: pluralize hidden-achievement count, skip failed fetches --- app/src/main/java/app/gamenative/service/SteamService.kt | 2 ++ .../app/gamenative/ui/screen/library/LibraryAppScreen.kt | 3 ++- app/src/main/res/values-da/strings.xml | 5 ++++- app/src/main/res/values-de/strings.xml | 5 ++++- app/src/main/res/values-es/strings.xml | 5 ++++- app/src/main/res/values-fr/strings.xml | 5 ++++- app/src/main/res/values-it/strings.xml | 5 ++++- app/src/main/res/values-ja/strings.xml | 4 +++- app/src/main/res/values-ko/strings.xml | 4 +++- app/src/main/res/values-pl/strings.xml | 7 ++++++- app/src/main/res/values-pt-rBR/strings.xml | 5 ++++- app/src/main/res/values-ro/strings.xml | 6 +++++- app/src/main/res/values-ru/strings.xml | 7 ++++++- app/src/main/res/values-uk/strings.xml | 7 ++++++- app/src/main/res/values-zh-rCN/strings.xml | 4 +++- app/src/main/res/values-zh-rTW/strings.xml | 4 +++- app/src/main/res/values/strings.xml | 5 ++++- 17 files changed, 67 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 9efcfd687b..1b0e6d9030 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -3088,6 +3088,8 @@ class SteamService : Service(), IChallengeUrlChanged { 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) 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 7626bd5e22..2fc2cbdc6b 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 @@ -125,6 +125,7 @@ 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 @@ -1403,7 +1404,7 @@ private fun HiddenAchievementsSummary(count: Int, onClick: () -> Unit) { } Column(modifier = Modifier.weight(1f)) { Text( - text = stringResource(R.string.achievements_hidden_remaining, count), + text = pluralStringResource(R.plurals.achievements_hidden_remaining, count, count), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 897a1a2498..c0aa847505 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1954,7 +1954,10 @@ Låst op den %1$s kl. %2$s Alle præstationer låst op I alt - %1$d skjulte præstationer tilbage + + %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. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e1f1a2e711..5a95d5e59e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2024,7 +2024,10 @@ Freigeschaltet am %1$s um %2$s Alle Erfolge freigeschaltet Gesamt - %1$d verborgene Errungenschaften übrig + + %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. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 50f2126b43..22ffb4f883 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2082,7 +2082,10 @@ Desbloqueado el %1$s a las %2$s Todos los logros desbloqueados Total - %1$d logros ocultos restantes + + %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. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a6d3dcb60a..b37e8f6e6f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2084,7 +2084,10 @@ Débloqué le %1$s à %2$s Tous les succès débloqués Total - %1$d succès cachés restants + + %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é. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dad26f8be0..6a78429866 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2075,7 +2075,10 @@ Sbloccato il %1$s alle %2$s Tutti i traguardi sbloccati Totale - %1$d obiettivi nascosti rimanenti + + %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. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 21b248b2d9..8b98eb4b53 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2041,7 +2041,9 @@ %1$s %2$s に解除 すべての実績を解除しました 合計 - 隠し実績 残り%1$d個 + + 隠し実績 残り%1$d個 + 各実績の詳細は解除すると表示されます 隠し実績を表示しますか? 残りの隠し実績とその詳細を一時的に表示します。記憶されません。 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b256f140b9..42e18b1410 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2082,7 +2082,9 @@ %1$s %2$s에 달성 모든 업적 달성 총계 - 숨겨진 도전 과제 %1$d개 남음 + + 숨겨진 도전 과제 %1$d개 남음 + 각 도전 과제의 세부 정보는 잠금 해제 시 공개됩니다 숨겨진 도전 과제를 표시할까요? 남은 비밀 도전 과제와 세부 정보를 지금만 표시합니다. 기억되지 않습니다. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 53c9b443c8..af946ec627 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2082,7 +2082,12 @@ Odblokowano %1$s o %2$s Wszystkie osiągnięcia odblokowane Razem - Pozostało ukrytych osiągnięć: %1$d + + 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. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index bf8ecdb6df..82f830e39a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1954,7 +1954,10 @@ Desbloqueado em %1$s às %2$s Todas as conquistas desbloqueadas Total - %1$d conquistas ocultas restantes + + %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. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 31dcef2cbc..50163ffbc6 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2085,7 +2085,11 @@ Deblocat pe %1$s la %2$s Toate realizările deblocate Total - %1$d realizări ascunse rămase + + %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. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6a59534969..be6e703a06 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2010,7 +2010,12 @@ https://gamenative.app Разблокировано %1$s в %2$s Все достижения разблокированы Всего - Осталось скрытых достижений: %1$d + + Осталось %1$d скрытое достижение + Осталось %1$d скрытых достижения + Осталось %1$d скрытых достижений + Осталось %1$d скрытых достижений + Подробности каждого достижения будут раскрыты после разблокировки Показать скрытые достижения? Временно показывает оставшиеся секретные достижения и их детали. Это не запоминается. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ff68537b23..cc60099ad3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2078,7 +2078,12 @@ Розблоковано %1$s о %2$s Усі досягнення розблоковано Усього - Залишилось прихованих досягнень: %1$d + + Залишилося %1$d приховане досягнення + Залишилося %1$d приховані досягнення + Залишилося %1$d прихованих досягнень + Залишилося %1$d прихованих досягнень + Деталі кожного досягнення буде розкрито після розблокування Показати приховані досягнення? Тимчасово показує решту секретних досягнень та їхні деталі. Це не запам’ятовується. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f26212b475..eb5a13b223 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2102,7 +2102,9 @@ %1$s %2$s 解锁 所有成就已解锁 总数 - 还有 %1$d 个隐藏成就 + + 还有 %1$d 个隐藏成就 + 每个成就的详情将在解锁后揭晓 显示隐藏成就? 暂时显示剩余的隐藏成就及其详情,不会被记住。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4e1746dc07..b4068603ef 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2093,7 +2093,9 @@ %1$s %2$s 解鎖 所有成就已解鎖 總數 - 還有 %1$d 個隱藏成就 + + 還有 %1$d 個隱藏成就 + 每個成就的詳細資訊將在解鎖後揭曉 顯示隱藏成就? 暫時顯示剩餘的隱藏成就及其詳情,不會被記住。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c488dddd7..78cc36e3bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2083,7 +2083,10 @@ Unlocked on %1$s at %2$s All achievements unlocked Total - %1$d hidden achievements remaining + + %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. From 182119f258b355532f18f18f892020cd35608d71 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Tue, 14 Jul 2026 16:33:42 +0200 Subject: [PATCH 06/12] Make steamLanguageForAppLocale locale-injectable and unit-test the mapping --- .../java/app/gamenative/utils/SteamUtils.kt | 3 +- .../utils/SteamUtilsLanguageTest.kt | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/app/gamenative/utils/SteamUtilsLanguageTest.kt diff --git a/app/src/main/java/app/gamenative/utils/SteamUtils.kt b/app/src/main/java/app/gamenative/utils/SteamUtils.kt index 06973b9dd7..b1a33fc603 100644 --- a/app/src/main/java/app/gamenative/utils/SteamUtils.kt +++ b/app/src/main/java/app/gamenative/utils/SteamUtils.kt @@ -150,8 +150,7 @@ object SteamUtils { * 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(): String { - val locale = Locale.getDefault() + fun steamLanguageForAppLocale(locale: Locale = Locale.getDefault()): String { return when (locale.language) { "ko" -> "koreana" // Steam splits Spanish into Castilian ("spanish") and Latin American ("latam"). 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")) + } +} From 150ab670bb9b6ba938cf7256dd69c1722a06d2ab Mon Sep 17 00:00:00 2001 From: VinceBT Date: Fri, 17 Jul 2026 10:55:31 +0200 Subject: [PATCH 07/12] Bump JavaSteam to 1.8.0.1-24-SNAPSHOT for achievement progress --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eff1ae7158..dce8ba79c9 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index faad503adc..fc99038cbb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-23-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest +javasteam = "1.8.0.1-24-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit From 88b6e38eaf92a03d0654667e328a38e418ec823b Mon Sep 17 00:00:00 2001 From: VinceBT Date: Thu, 27 Aug 2026 00:04:27 +0200 Subject: [PATCH 08/12] :recycle: Move achievement fetching behind storefront hooks The base app screen no longer reaches for Steam. Storefronts opt in and supply their own achievements, so only the Steam screen knows about stats. Steam also reports when a logon lands, which retries a fetch that ran before sign-in completed. --- .../screen/library/appscreen/BaseAppScreen.kt | 46 +++++++++++-------- .../library/appscreen/SteamAppScreen.kt | 26 +++++++++++ 2 files changed, 52 insertions(+), 20 deletions(-) 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 4927dec31a..8afbd878c7 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 @@ -648,6 +648,15 @@ abstract class BaseAppScreen { protected open fun supportsSaveTransfer(libraryItem: LibraryItem): Boolean = false + protected open val supportsAchievements: Boolean = false + + /** Null when the fetch failed, so the caller can retry. Empty means the game has none. */ + protected open suspend fun fetchAchievements(libraryItem: LibraryItem): List? = null + + /** Changes once the storefront can answer, retrying a fetch that ran too early. */ + @Composable + protected open fun achievementsReadyKey(): Any = Unit + protected open suspend fun exportSaves( context: Context, libraryItem: LibraryItem, @@ -1279,27 +1288,24 @@ 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) + val achievementsReadyKey = achievementsReadyKey() + LaunchedEffect(libraryItem.appId, achievementsReadyKey) { + if (!supportsAchievements) return@LaunchedEffect + // Retry so a transient error doesn't silently drop the section. + repeat(3) { attempt -> + val result = try { + fetchAchievements(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) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt index 0d5e3c987e..5f3b3dfa0c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/SteamAppScreen.kt @@ -43,16 +43,19 @@ import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem +import app.gamenative.enums.LoginResult import app.gamenative.enums.Marker import app.gamenative.enums.PathType import app.gamenative.enums.SyncResult import app.gamenative.events.AndroidEvent +import app.gamenative.events.SteamEvent import app.gamenative.service.DownloadService import app.gamenative.service.SteamService import app.gamenative.service.SteamService.Companion.getAppDirPath import app.gamenative.ui.component.dialog.MessageDialog import app.gamenative.ui.component.dialog.LoadingDialog import app.gamenative.ui.component.dialog.state.MessageDialogState +import app.gamenative.ui.data.Achievement import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType @@ -680,6 +683,29 @@ class SteamAppScreen : BaseAppScreen() { return libraryItem.gameSource == app.gamenative.data.GameSource.STEAM } + override val supportsAchievements: Boolean = true + + override suspend fun fetchAchievements(libraryItem: LibraryItem): List? = + withContext(Dispatchers.IO) { SteamService.fetchAchievementsForDisplay(getGameId(libraryItem)) } + + // Stats need a logged-on session, so a page opened before sign-in finds nothing. + @Composable + override fun achievementsReadyKey(): Any { + var logons by remember { mutableIntStateOf(0) } + DisposableEffect(true) { + val onLogonEnded: (SteamEvent.LogonEnded) -> Unit = { event -> + if (event.loginResult == LoginResult.Success) logons++ + } + + PluviaApp.events.on(onLogonEnded) + + onDispose { + PluviaApp.events.off(onLogonEnded) + } + } + return logons + } + override suspend fun exportSaves( context: Context, libraryItem: LibraryItem, From 1bca205ab0417256fd16ea8a4e3f44acd22e04b9 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Thu, 27 Aug 2026 00:04:36 +0200 Subject: [PATCH 09/12] :sparkles: Show achievement progress as a bar on the game page The widget gains a progress bar with the unlocked count and percentage beside it, replacing the separate count column. Icons now divide a full row evenly so the strip ends flush with the bar. The bar is shared with the per-achievement ones in the dialog. It draws a single continuous track with the fill over it, so the remaining part no longer reads as a second bar butted against the fill. Also memoizes the sort and the hidden split, and falls back to the internal name when Steam leaves a localized one blank. --- .../ui/component/GradientProgressBar.kt | 53 ++++++++ .../ui/screen/library/LibraryAppScreen.kt | 127 +++++++++--------- app/src/main/res/values-da/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-ko/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values-zh-rCN/strings.xml | 2 +- app/src/main/res/values-zh-rTW/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 17 files changed, 135 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt diff --git a/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt b/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt new file mode 100644 index 0000000000..3f046f00cf --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt @@ -0,0 +1,53 @@ +package app.gamenative.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.gamenative.ui.theme.BrandGradient + +/** + * Rounded progress bar drawn as one continuous track with the fill over it, so the unfilled part + * reads as the same bar. The gradient spans the whole bar, so its colors hold still as the fill + * grows. A single entry in [colors] gives a flat fill. + */ +@Composable +fun GradientProgressBar( + progress: Float, + modifier: Modifier = Modifier, + height: Dp = 6.dp, + colors: List = BrandGradient, + trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f), +) { + val fraction = progress.coerceIn(0f, 1f) + Canvas( + modifier = modifier + .fillMaxWidth() + .height(height), + ) { + val radius = CornerRadius(size.height / 2f) + drawRoundRect(color = trackColor, cornerRadius = radius) + if (fraction > 0f) { + // Keep a barely-started fill from collapsing into a lens shape. + val fillWidth = (size.width * fraction).coerceAtLeast(size.height) + drawRoundRect( + brush = if (colors.size == 1) { + SolidColor(colors.first()) + } else { + Brush.horizontalGradient(colors = colors, startX = 0f, endX = size.width) + }, + size = Size(fillWidth, size.height), + cornerRadius = radius, + ) + } + } +} 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 d3e115edf9..cb6c87279b 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 @@ -25,6 +25,7 @@ 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.GradientProgressBar import app.gamenative.ui.component.InfoCard import app.gamenative.ui.component.topbar.BackButton import app.gamenative.ui.data.Achievement @@ -1304,6 +1305,10 @@ fun GameMigrationDialog( // Shared grayscale filter for locked achievement icons. private val grayMatrix = ColorMatrix().apply { setToSaturation(0f) } +// Steam leaves some localized names blank. +private val Achievement.label: String + get() = displayName.ifEmpty { name ?: "" } + private fun Achievement.previewIconUrl(): String? = if (isUnlocked) icon.ifEmpty { iconGray } else iconGray ?: icon.ifEmpty { null } @@ -1333,7 +1338,7 @@ private fun AchievementIcon(ach: Achievement, size: Dp, corner: Dp, masked: Bool imageModel = { iconUrl ?: "" }, imageOptions = ImageOptions( contentScale = ContentScale.Crop, - contentDescription = ach.displayName ?: ach.name, + contentDescription = ach.label, colorFilter = if (ach.isUnlocked) null else ColorFilter.colorMatrix(grayMatrix), ), modifier = box, @@ -1349,12 +1354,10 @@ private fun AchievementProgressBar(current: Float, max: Float, textStyle: TextSt verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - LinearProgressIndicator( - progress = { fraction }, + GradientProgressBar( + progress = fraction, modifier = Modifier.weight(1f), - trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f), - gapSize = 0.dp, - drawStopIndicator = {}, + height = 5.dp, ) Text( text = "${current.toInt()} / ${max.toInt()}", @@ -1390,15 +1393,15 @@ private fun AchievementRow(ach: Achievement, focusRequester: FocusRequester? = n AchievementIcon(ach = ach, size = 40.dp, corner = 6.dp) Column(modifier = Modifier.weight(1f)) { Text( - text = ach.displayName ?: ach.name ?: "", + text = ach.label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (!ach.description.isNullOrEmpty()) { + if (ach.description.isNotEmpty()) { Text( - text = ach.description!!, + text = ach.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -1491,7 +1494,7 @@ private fun AchievementDetailDialog(ach: Achievement, onDismiss: () -> Unit) { }, title = { Text( - text = ach.displayName ?: ach.name ?: "", + text = ach.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, @@ -1503,9 +1506,9 @@ private fun AchievementDetailDialog(ach: Achievement, onDismiss: () -> Unit) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (!ach.description.isNullOrEmpty()) { + if (ach.description.isNotEmpty()) { Text( - text = ach.description!!, + text = ach.description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -1545,15 +1548,16 @@ private fun AchievementDetailDialog(ach: Achievement, onDismiss: () -> Unit) { 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 }, - ) + val sortedAchievements = remember(achievements) { + achievements.sortedWith( + compareByDescending { it.isUnlocked } + .thenByDescending { it.unlockTimestamp }, + ) + } Spacer(modifier = Modifier.height(10.dp)) @@ -1565,22 +1569,25 @@ private fun AchievementsRow( 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 + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // Fit as many icons as the width allows. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val minIconSize = 48.dp val spacing = 8.dp - val fit = ((maxWidth + spacing) / (iconSize + spacing)) - .toInt() - .coerceIn(1, sortedAchievements.size) val total = sortedAchievements.size + val slotsByWidth = ((maxWidth + spacing) / (minIconSize + spacing)) + .toInt() + .coerceAtLeast(1) + val slots = slotsByWidth.coerceAtMost(total) + // A full row divides the width evenly so the strip ends flush with the bar below. + val iconSize = if (total >= slotsByWidth) { + (maxWidth - spacing * (slots - 1)) / slots + } else { + minIconSize + } // 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 + val showStack = total > slots + val iconCount = if (showStack) (slots - 1).coerceAtLeast(0) else slots Row(horizontalArrangement = Arrangement.spacedBy(spacing)) { sortedAchievements.take(iconCount).forEach { ach -> AchievementIcon( @@ -1605,6 +1612,7 @@ private fun AchievementsRow( imageModel = { nextUrl ?: "" }, imageOptions = ImageOptions( contentScale = ContentScale.Crop, + contentDescription = next.label, colorFilter = ColorFilter.colorMatrix(grayMatrix), ), modifier = Modifier.fillMaxSize(), @@ -1628,35 +1636,34 @@ private fun AchievementsRow( } } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - 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), - ) - } - } + GradientProgressBar( + progress = if (totalCount > 0) unlockedCount.toFloat() / totalCount else 0f, + modifier = Modifier.weight(1f), + height = 8.dp, + ) + // Floors, so only a full set reads as 100%. + val percent = if (totalCount > 0) unlockedCount * 100 / totalCount else 0 Text( - text = stringResource(R.string.achievements_total), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.achievements_progress_count, unlockedCount, totalCount, percent), + style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + softWrap = false, ) + // 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), + ) + } } } } @@ -1674,7 +1681,6 @@ 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 } } @@ -1683,7 +1689,10 @@ private fun AchievementsDialog( } val dismiss = { visibleState.targetState = false } - // Reveal is session-only, not remembered. + // Secret achievements collapse into one row until revealed. Reveal is session-only. + val (hiddenLocked, visibleAchievements) = remember(achievements) { + achievements.partition { it.isHiddenLocked } + } var revealHidden by remember { mutableStateOf(false) } var showRevealConfirm by remember { mutableStateOf(false) } var detailAchievement by remember { mutableStateOf(null) } @@ -1756,8 +1765,6 @@ private fun AchievementsDialog( 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 } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 7dbd4f191b..9018c2d18f 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2044,7 +2044,7 @@ Alle præstationer Låst op den %1$s kl. %2$s Alle præstationer låst op - I alt + %1$d / %2$d (%3$d%%) %1$d skjult præstation tilbage %1$d skjulte præstationer tilbage diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 0952225155..f91c8ab0a4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2114,7 +2114,7 @@ Alle Erfolge Freigeschaltet am %1$s um %2$s Alle Erfolge freigeschaltet - Gesamt + %1$d / %2$d (%3$d%%) %1$d verborgene Errungenschaft übrig %1$d verborgene Errungenschaften übrig diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 44c349b2f9..b350ddd9cd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2172,7 +2172,7 @@ Todos los logros Desbloqueado el %1$s a las %2$s Todos los logros desbloqueados - Total + %1$d / %2$d (%3$d%%) %1$d logro oculto restante %1$d logros ocultos restantes diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index beaf07ad12..a2db3f3bc9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2192,7 +2192,7 @@ Tous les succès Débloqué le %1$s à %2$s Tous les succès débloqués - Total + %1$d / %2$d (%3$d%%) %1$d succès caché restant %1$d succès cachés restants diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 64fdc91e26..4046803a4d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2165,7 +2165,7 @@ Tutti gli obiettivi Sbloccato il %1$s alle %2$s Tutti i traguardi sbloccati - Totale + %1$d / %2$d (%3$d%%) %1$d obiettivo nascosto rimanente %1$d obiettivi nascosti rimanenti diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4cceced62a..8a5c5e7814 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2130,7 +2130,7 @@ すべての実績 %1$s %2$s に解除 すべての実績を解除しました - 合計 + %1$d / %2$d (%3$d%%) 隠し実績 残り%1$d個 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 9436e1ce98..e740cfefaf 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2171,7 +2171,7 @@ 모든 업적 %1$s %2$s에 달성 모든 업적 달성 - 총계 + %1$d / %2$d (%3$d%%) 숨겨진 도전 과제 %1$d개 남음 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1fe6517922..d5cf553554 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2174,7 +2174,7 @@ Wszystkie osiągnięcia Odblokowano %1$s o %2$s Wszystkie osiągnięcia odblokowane - Razem + %1$d / %2$d (%3$d%%) Pozostało %1$d ukryte osiągnięcie Pozostały %1$d ukryte osiągnięcia diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ad095a16c4..13474982d9 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2044,7 +2044,7 @@ Todas as conquistas Desbloqueado em %1$s às %2$s Todas as conquistas desbloqueadas - Total + %1$d / %2$d (%3$d%%) %1$d conquista oculta restante %1$d conquistas ocultas restantes diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index bb722efb0b..5fabbc4d7e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2176,7 +2176,7 @@ Toate realizările Deblocat pe %1$s la %2$s Toate realizările deblocate - Total + %1$d / %2$d (%3$d%%) %1$d realizare ascunsă rămasă %1$d realizări ascunse rămase diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 00ef17de60..6063f0c174 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2102,7 +2102,7 @@ https://gamenative.app Все достижения Разблокировано %1$s в %2$s Все достижения разблокированы - Всего + %1$d / %2$d (%3$d%%) Осталось %1$d скрытое достижение Осталось %1$d скрытых достижения diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ccd6bbbab4..81b7d6cb73 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2170,7 +2170,7 @@ Всі досягнення Розблоковано %1$s о %2$s Усі досягнення розблоковано - Усього + %1$d / %2$d (%3$d%%) Залишилося %1$d приховане досягнення Залишилося %1$d приховані досягнення diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fbaac68b75..4ed0eb6c16 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2191,7 +2191,7 @@ 所有成就 %1$s %2$s 解锁 所有成就已解锁 - 总数 + %1$d / %2$d (%3$d%%) 还有 %1$d 个隐藏成就 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index b5b60f0629..e33738f2e9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2182,7 +2182,7 @@ 所有成就 %1$s %2$s 解鎖 所有成就已解鎖 - 總數 + %1$d / %2$d (%3$d%%) 還有 %1$d 個隱藏成就 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb1a4aab08..0a73e8062a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2304,7 +2304,7 @@ All Achievements Unlocked on %1$s at %2$s All achievements unlocked - Total + %1$d / %2$d (%3$d%%) %1$d hidden achievement remaining %1$d hidden achievements remaining From 36fb0e4aa58cc52f5e947ef61336c3d27f0497cb Mon Sep 17 00:00:00 2001 From: VinceBT Date: Thu, 27 Aug 2026 00:04:45 +0200 Subject: [PATCH 10/12] :bug: Restore the narrower focus ring on info cards Extracting the card into a shared component dropped the explicit ring width, so every card on the game page drew the wider default. --- app/src/main/java/app/gamenative/ui/component/InfoCard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/ui/component/InfoCard.kt b/app/src/main/java/app/gamenative/ui/component/InfoCard.kt index 88f8100170..148c702389 100644 --- a/app/src/main/java/app/gamenative/ui/component/InfoCard.kt +++ b/app/src/main/java/app/gamenative/ui/component/InfoCard.kt @@ -77,7 +77,7 @@ fun InfoCard( modifier = modifier .bringIntoViewRequester(bringIntoViewRequester) .then(interactive) - .focusRing(interactionSource, shape), + .focusRing(interactionSource, shape, width = 2.dp), shape = shape, color = MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = 2.dp, From a37171177d0ebc510471740ba08d6492e8d66083 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Thu, 27 Aug 2026 00:04:45 +0200 Subject: [PATCH 11/12] :bug: Ignore nameless blocks in the English achievement fallback A null name keyed the lookup map, so one entry could answer every achievement and repeat its text across the list. --- app/src/main/java/app/gamenative/service/SteamService.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 82005bed46..4d50a6c9e8 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -3214,7 +3214,11 @@ class SteamService : Service(), IChallengeUrlChanged { if (appLanguage == "english") { emptyMap() } else { - userStats.getExpandedAchievements("english").associateBy { it.name } + // A nameless block can't be matched, and its null key would swallow + // every lookup. + userStats.getExpandedAchievements("english") + .associateBy { it.name } + .filterKeys { it != null } } } localized.map { block -> From 2fb880e4a127e8ec1d9307c7361903fb5bc86687 Mon Sep 17 00:00:00 2001 From: VinceBT Date: Mon, 31 Aug 2026 11:01:43 +0200 Subject: [PATCH 12/12] :bug: Keep an empty gradient color list from crashing the progress bar --- .../java/app/gamenative/ui/component/GradientProgressBar.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt b/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt index 3f046f00cf..e4cda01f35 100644 --- a/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt +++ b/app/src/main/java/app/gamenative/ui/component/GradientProgressBar.kt @@ -40,10 +40,10 @@ fun GradientProgressBar( // Keep a barely-started fill from collapsing into a lens shape. val fillWidth = (size.width * fraction).coerceAtLeast(size.height) drawRoundRect( - brush = if (colors.size == 1) { - SolidColor(colors.first()) - } else { + brush = if (colors.size > 1) { Brush.horizontalGradient(colors = colors, startX = 0f, endX = size.width) + } else { + SolidColor(colors.firstOrNull() ?: trackColor) }, size = Size(fillWidth, size.height), cornerRadius = radius,