From 3e80cbd31fe07f835d96ae3973b37ee0d8c23e33 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 14:28:31 -0400 Subject: [PATCH 01/18] Add a Favourites tab and star toggle to the library Adds a simple way to mark games as favourites and find them again quickly. - Store favourites locally as a set of appIds, so they work across every source (Steam, GOG, Epic, Amazon, custom games) without needing a login - Add FavouritesManager, which exposes the set as a StateFlow so the list and the game cards stay in sync as favourites change - Add a Favourites tab to the library, with a count badge - Add a star toggle on the list and grid cards - Add an Add/Remove favourite entry to the game options menu on the detail screen, so it is available for every source in one place - Add strings for the default locale and the existing translations --- .../main/java/app/gamenative/PrefManager.kt | 16 ++++++ .../app/gamenative/data/FavouritesManager.kt | 35 ++++++++++++ .../app/gamenative/ui/data/LibraryState.kt | 1 + .../gamenative/ui/enums/AppOptionMenuType.kt | 2 + .../app/gamenative/ui/enums/LibraryTab.kt | 9 +++ .../gamenative/ui/model/LibraryViewModel.kt | 27 +++++++++ .../ui/screen/library/LibraryScreen.kt | 1 + .../screen/library/appscreen/BaseAppScreen.kt | 17 ++++++ .../library/components/FavouriteStarButton.kt | 57 +++++++++++++++++++ .../library/components/GameOptionsPanel.kt | 6 ++ .../library/components/LibraryGridCard.kt | 9 +++ .../library/components/LibraryListCard.kt | 5 ++ 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 | 5 ++ app/src/main/res/values-ko/strings.xml | 5 ++ app/src/main/res/values-pl/strings.xml | 5 ++ app/src/main/res/values-pt-rBR/strings.xml | 5 ++ app/src/main/res/values-ro/strings.xml | 5 ++ app/src/main/res/values-ru/strings.xml | 5 ++ app/src/main/res/values-uk/strings.xml | 5 ++ app/src/main/res/values-zh-rCN/strings.xml | 5 ++ app/src/main/res/values-zh-rTW/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 5 ++ 27 files changed, 260 insertions(+) create mode 100644 app/src/main/java/app/gamenative/data/FavouritesManager.kt create mode 100644 app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 5ba1a36910..517d643eb1 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1302,6 +1302,22 @@ object PrefManager { setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value)) } + // Games the user has marked as favourite, stored as a set of LibraryItem.appId values so they + // work across every source (Steam, GOG, Epic, Amazon, custom games) without needing an account. + private val FAVOURITE_APP_IDS = stringPreferencesKey("favourite_app_ids") + var favouriteAppIds: Set + get() { + val value = getPref(FAVOURITE_APP_IDS, "[]") + return try { + Json.decodeFromString>(value) + } catch (e: Exception) { + emptySet() + } + } + set(value) { + setPref(FAVOURITE_APP_IDS, Json.encodeToString(value)) + } + // Add new setting for Wine debug logging private val ENABLE_WINE_DEBUG = booleanPreferencesKey("enable_wine_debug") var enableWineDebug: Boolean diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavouritesManager.kt new file mode 100644 index 0000000000..ae055c147a --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavouritesManager.kt @@ -0,0 +1,35 @@ +package app.gamenative.data + +import app.gamenative.PrefManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Keeps track of which games the user has marked as favourite. + * + * Favourites are stored as a set of [LibraryItem.appId] values, so they work across every source + * (Steam, GOG, Epic, Amazon and custom games) without needing an account. The current set is + * exposed as a [StateFlow] so the library list and the game cards update as soon as it changes, + * while [PrefManager] keeps the values on disk between sessions. + */ +object FavouritesManager { + private val _favourites = MutableStateFlow(PrefManager.favouriteAppIds) + + /** The set of favourited app ids. Observe this to react to changes. */ + val favourites: StateFlow> = _favourites.asStateFlow() + + fun isFavourite(appId: String): Boolean = _favourites.value.contains(appId) + + /** Adds the game if it is not a favourite yet, or removes it if it already is. */ + fun toggle(appId: String) = setFavourite(appId, !isFavourite(appId)) + + fun setFavourite(appId: String, favourite: Boolean) { + val current = _favourites.value + if (favourite == current.contains(appId)) return + + val updated = if (favourite) current + appId else current - appId + _favourites.value = updated + PrefManager.favouriteAppIds = updated + } +} diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index ec17d918a5..1230183125 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -71,6 +71,7 @@ data class LibraryState( val epicCount: Int = 0, val amazonCount: Int = 0, val localCount: Int = 0, + val favouritesCount: Int = 0, ) /** diff --git a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt index 1f846481e9..42a49dd73e 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt @@ -35,4 +35,6 @@ enum class AppOptionMenuType(@StringRes val title: Int) { ManageWorkshop(R.string.option_manage_workshop), ManageMods(R.string.option_manage_mods), ChangeBranch(R.string.change_branch), + AddToFavourites(R.string.option_add_to_favourites), + RemoveFromFavourites(R.string.option_remove_from_favourites), } diff --git a/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt index f617b77abf..bd0e5edf68 100644 --- a/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt +++ b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt @@ -37,6 +37,15 @@ enum class LibraryTab( showAmazon = true, installedOnly = false, ), + FAVOURITES( + labelResId = R.string.tab_favourites, + showCustom = true, + showSteam = true, + showGoG = true, + showEpic = true, + showAmazon = true, + installedOnly = false, + ), STEAM( labelResId = R.string.tab_steam, showCustom = false, diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 884332a57d..c4179fb94c 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -12,6 +12,7 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.data.FavouritesManager import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.data.gog.GogRecommendationsRepository @@ -70,6 +71,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -177,6 +179,16 @@ class LibraryViewModel @Inject constructor( } } + // Re-filter whenever the set of favourite games changes, so the Favourites tab and the + // tab badge stay in sync as the user stars or unstars games. + viewModelScope.launch(Dispatchers.IO) { + FavouritesManager.favourites + .drop(1) + .collect { + onFilterApps(paginationCurrentPage) + } + } + @OptIn(ExperimentalCoroutinesApi::class) viewModelScope.launch(Dispatchers.IO) { // Re-create the underlying DAO Flow whenever the EXPIRED filter is toggled, @@ -946,12 +958,20 @@ class LibraryViewModel @Inject constructor( // sources can't match it — keep them out of the combined list (and their tab counts). val steamCollectionSelected = allowedSteamAppIds != null + val favouriteIds = FavouritesManager.favourites.value + val combined = buildList { if (includeSteam) addAll(steamEntries) if (includeOpen && !steamCollectionSelected) addAll(customEntries) if (includeGOG && !steamCollectionSelected) addAll(gogEntries) if (includeEpic && !steamCollectionSelected) addAll(epicEntries) if (includeAmazon && !steamCollectionSelected) addAll(amazonEntries) + }.let { entries -> + if (currentTab == app.gamenative.ui.enums.LibraryTab.FAVOURITES) { + entries.filter { it.item.appId in favouriteIds } + } else { + entries + } }.sortedWith(sortComparator).mapIndexed { idx, entry -> entry.item.copy(index = idx, isInstalled = entry.isInstalled) } @@ -1039,6 +1059,13 @@ class LibraryViewModel @Inject constructor( amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0, localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0, steamCollectionCounts = steamCollectionCounts, + favouritesCount = buildList { + if (currentState.showSteamInLibrary) addAll(steamEntries) + if (currentState.showCustomGamesInLibrary) addAll(customEntries) + if (currentState.showGOGInLibrary && GOGService.hasStoredCredentials(context)) addAll(gogEntries) + if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) addAll(epicEntries) + if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) + }.count { it.item.appId in favouriteIds }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index fa908eae7f..e5e5b2374f 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -1048,6 +1048,7 @@ private fun LibraryScreenContent( currentTab = state.currentTab, tabCounts = mapOf( LibraryTab.ALL to state.allCount, + LibraryTab.FAVOURITES to state.favouritesCount, LibraryTab.STEAM to state.steamCount, LibraryTab.GOG to state.gogCount, LibraryTab.EPIC to state.epicCount, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 03d6f40f59..3778b39268 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 @@ -27,6 +27,7 @@ import androidx.core.net.toUri import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.GameSource +import app.gamenative.data.FavouritesManager import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent import app.gamenative.mods.ModContainerResolver @@ -720,6 +721,21 @@ abstract class BaseAppScreen { return emptyList() } + @Composable + private fun getFavouriteOption(libraryItem: LibraryItem): AppMenuOption { + val isFavourite = FavouritesManager.isFavourite(libraryItem.appId) + return AppMenuOption( + optionType = if (isFavourite) { + AppOptionMenuType.RemoveFromFavourites + } else { + AppOptionMenuType.AddToFavourites + }, + onClick = { + FavouritesManager.toggle(libraryItem.appId) + }, + ) + } + @Composable private fun getSubmitFeedbackOption(context: Context, libraryItem: LibraryItem): AppMenuOption { return AppMenuOption( @@ -960,6 +976,7 @@ abstract class BaseAppScreen { } // Always available options + menuOptions.add(getFavouriteOption(libraryItem)) menuOptions.add(getSubmitFeedbackOption(context, libraryItem)) menuOptions.add(getGetSupportOption(context)) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt new file mode 100644 index 0000000000..95e14e6752 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt @@ -0,0 +1,57 @@ +package app.gamenative.ui.screen.library.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.gamenative.R +import app.gamenative.data.FavouritesManager + +/** + * A star button that shows whether a game is a favourite and toggles it when tapped. + * + * It observes [FavouritesManager] directly, so it can be dropped onto any card or screen without + * threading callbacks through the surrounding composables. + * + * @param onImage when true, the icon uses a light tint so it stays readable on top of cover art. + */ +@Composable +internal fun FavouriteStarButton( + appId: String, + modifier: Modifier = Modifier, + iconSize: Int = 20, + onImage: Boolean = false, +) { + val favourites by FavouritesManager.favourites.collectAsStateWithLifecycle() + val isFavourite = appId in favourites + + val tint = when { + isFavourite -> MaterialTheme.colorScheme.primary + onImage -> Color.White.copy(alpha = 0.85f) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + IconButton( + onClick = { FavouritesManager.toggle(appId) }, + modifier = modifier, + ) { + Icon( + imageVector = if (isFavourite) Icons.Filled.Star else Icons.Filled.StarOutline, + contentDescription = stringResource( + if (isFavourite) R.string.favourite_remove else R.string.favourite_add, + ), + tint = tint, + modifier = Modifier.size(iconSize.dp), + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt index 461238a961..5f55524ece 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt @@ -54,6 +54,8 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarOutline import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material3.HorizontalDivider @@ -351,6 +353,8 @@ private fun getIconForOption(type: AppOptionMenuType): ImageVector { AppOptionMenuType.ManageWorkshop -> Icons.Default.Build AppOptionMenuType.ManageMods -> Icons.Default.Extension AppOptionMenuType.ChangeBranch -> Icons.AutoMirrored.Filled.CallSplit + AppOptionMenuType.AddToFavourites -> Icons.Filled.StarOutline + AppOptionMenuType.RemoveFromFavourites -> Icons.Filled.Star } } @@ -368,6 +372,8 @@ private fun groupOptions(options: List): Map quickActions.add(option) // Game Management diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt index 6ab394aca6..9400eef075 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt @@ -367,6 +367,15 @@ internal fun GridViewCard( .padding(top = topIconPadding, end = topIconPadding), iconSize = if (isCapsule) 14 else 12, ) + + FavouriteStarButton( + appId = appInfo.appId, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 2.dp, bottom = 2.dp), + iconSize = if (isCapsule) 18 else 20, + onImage = true, + ) } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt index 585e131bd2..89da662d6a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt @@ -198,6 +198,11 @@ internal fun ListViewCard( showLabel = true, ) } + + FavouriteStarButton( + appId = appInfo.appId, + iconSize = 22, + ) } } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 48a704eaa0..b848819626 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1969,4 +1969,9 @@ Viser senest synkroniserede samlinger. Opret forbindelse for at opdatere. Ryd Kun standardsamlinger vises. Smarte samlinger understøttes ikke endnu. + Favoritter + Føj til favoritter + Fjern fra favoritter + Føj til favoritter + Fjern fra favoritter diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6315d3fa03..7b8c5c61e8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2039,4 +2039,9 @@ Es werden die zuletzt synchronisierten Sammlungen angezeigt. Zum Aktualisieren verbinden. Löschen Es werden nur Standardsammlungen angezeigt. Intelligente Sammlungen werden noch nicht unterstützt. + Favoriten + Zu Favoriten hinzufügen + Aus Favoriten entfernen + Zu Favoriten hinzufügen + Aus Favoriten entfernen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d8abd02c18..e03ffb9f2b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2097,4 +2097,9 @@ Mostrando las últimas colecciones sincronizadas. Conéctate para actualizar. Borrar Solo se muestran las colecciones estándar. Las colecciones inteligentes aún no son compatibles. + Favoritos + Añadir a favoritos + Quitar de favoritos + Añadir a favoritos + Quitar de favoritos diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e939d53fb2..2ead34a02f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2099,4 +2099,9 @@ Affichage des dernières collections synchronisées. Connectez-vous pour mettre à jour. Effacer Seules les collections standard sont affichées. Les collections dynamiques ne sont pas encore prises en charge. + Favoris + Ajouter aux favoris + Retirer des favoris + Ajouter aux favoris + Retirer des favoris diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 761f8a3fff..21d9c53d00 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2090,4 +2090,9 @@ Visualizzazione delle ultime raccolte sincronizzate. Connettiti per aggiornare. Cancella Vengono mostrate solo le raccolte standard. Le raccolte intelligenti non sono ancora supportate. + Preferiti + Aggiungi ai preferiti + Rimuovi dai preferiti + Aggiungi ai preferiti + Rimuovi dai preferiti diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5d0ac0b3ac..213f065bfb 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2056,4 +2056,9 @@ 最後に同期されたコレクションを表示しています。更新するには接続してください。 クリア 標準コレクションのみ表示されます。スマートコレクションはまだ対応していません。 + お気に入り + お気に入りに追加 + お気に入りから削除 + お気に入りに追加 + お気に入りから削除 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ac9606d772..e2b646a4e9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2097,4 +2097,9 @@ 마지막으로 동기화된 컬렉션을 표시하고 있습니다. 업데이트하려면 연결하세요. 지우기 표준 컬렉션만 표시됩니다. 스마트 컬렉션은 아직 지원되지 않습니다. + 즐겨찾기 + 즐겨찾기에 추가 + 즐겨찾기에서 제거 + 즐겨찾기에 추가 + 즐겨찾기에서 제거 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 4535d6cd88..ade3f3c99f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2097,4 +2097,9 @@ Wyświetlanie ostatnio zsynchronizowanych kolekcji. Połącz się, aby zaktualizować. Wyczyść Wyświetlane są tylko standardowe kolekcje. Kolekcje inteligentne nie są jeszcze obsługiwane. + Ulubione + Dodaj do ulubionych + Usuń z ulubionych + Dodaj do ulubionych + Usuń z ulubionych diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b7087eeda5..e1b08d04e4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1969,4 +1969,9 @@ Exibindo as últimas coleções sincronizadas. Conecte-se para atualizar. Limpar Apenas coleções padrão são exibidas. Coleções inteligentes ainda não são compatíveis. + Favoritos + Adicionar aos favoritos + Remover dos favoritos + Adicionar aos favoritos + Remover dos favoritos diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 4b1d7ef0d4..2ef6a1f1a8 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2100,4 +2100,9 @@ Se afișează ultimele colecții sincronizate. Conectează-te pentru a actualiza. Șterge Sunt afișate doar colecțiile standard. Colecțiile inteligente nu sunt încă acceptate. + Favorite + Adaugă la favorite + Elimină de la favorite + Adaugă la favorite + Elimină de la favorite diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 89272bf8c5..fff9f02bdb 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2025,4 +2025,9 @@ https://gamenative.app Показаны последние синхронизированные коллекции. Подключитесь для обновления. Очистить Показаны только обычные коллекции. Умные коллекции пока не поддерживаются. + Избранное + Добавить в избранное + Удалить из избранного + Добавить в избранное + Удалить из избранного diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index bc977015dd..049e1cb269 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2093,4 +2093,9 @@ Показано останні синхронізовані колекції. Підключіться, щоб оновити. Очистити Показано лише звичайні колекції. Розумні колекції ще не підтримуються. + Вибране + Додати до вибраного + Видалити з вибраного + Додати до вибраного + Видалити з вибраного diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ef3891881b..c095d8d1b2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2117,4 +2117,9 @@ 正在显示上次同步的收藏集。请连接以更新。 清除 仅显示标准收藏集。智能收藏集尚不受支持。 + 收藏 + 添加到收藏 + 从收藏中移除 + 添加到收藏 + 从收藏中移除 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 05650862d7..774d080708 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2108,4 +2108,9 @@ 正在顯示上次同步的收藏。請連線以更新。 清除 僅顯示標準收藏。智慧型收藏尚不支援。 + 收藏 + 加入收藏 + 從收藏中移除 + 加入收藏 + 從收藏中移除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dfd5518d88..f78fa9095d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,7 @@ Recommended Add or play some games to get GOG recommendations based on your library. All + Favourites Steam GOG Epic @@ -1622,6 +1623,10 @@ Reset container Get support Submit feedback + Add to favourites + Remove from favourites + Add to favourites + Remove from favourites Reset DRM Use known config Import saves From 2081e3d76841d7d4a4ff60bccbadd7b7b696ab5e Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 16:51:24 -0400 Subject: [PATCH 02/18] Extract favourites logic into FavouritesUtils and add unit tests Move the pure favourites logic (add, remove, toggle, filter, count) into a small FavouritesUtils helper so it can be unit tested on its own, mirroring the existing LibrarySortUtils pattern. FavouritesManager and LibraryViewModel now delegate to it. Adds FavouritesUtilsTest covering the add/remove/toggle transitions and the library filter and count behaviour. --- .../app/gamenative/data/FavouritesManager.kt | 4 +- .../app/gamenative/data/FavouritesUtils.kt | 26 ++++++ .../gamenative/ui/model/LibraryViewModel.kt | 20 +++-- .../gamenative/data/FavouritesUtilsTest.kt | 87 +++++++++++++++++++ 4 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/app/gamenative/data/FavouritesUtils.kt create mode 100644 app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavouritesManager.kt index ae055c147a..9bb2893076 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavouritesManager.kt @@ -26,9 +26,9 @@ object FavouritesManager { fun setFavourite(appId: String, favourite: Boolean) { val current = _favourites.value - if (favourite == current.contains(appId)) return + val updated = FavouritesUtils.apply(current, appId, favourite) + if (updated == current) return - val updated = if (favourite) current + appId else current - appId _favourites.value = updated PrefManager.favouriteAppIds = updated } diff --git a/app/src/main/java/app/gamenative/data/FavouritesUtils.kt b/app/src/main/java/app/gamenative/data/FavouritesUtils.kt new file mode 100644 index 0000000000..c0a7ab9639 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavouritesUtils.kt @@ -0,0 +1,26 @@ +package app.gamenative.data + +/** + * Pure helpers for the favourites feature. + * + * The logic is kept here, separate from [FavouritesManager] and the library view model, so it can + * be reasoned about and unit tested on its own without touching storage or Android state. + */ +internal object FavouritesUtils { + + /** Returns the favourites set after adding or removing [appId]. */ + fun apply(current: Set, appId: String, favourite: Boolean): Set = + if (favourite) current + appId else current - appId + + /** Returns the favourites set with [appId] flipped on or off. */ + fun toggle(current: Set, appId: String): Set = + apply(current, appId, appId !in current) + + /** Keeps only the [items] whose id (via [id]) is in [favourites], preserving order. */ + fun filter(items: List, favourites: Set, id: (T) -> String): List = + items.filter { id(it) in favourites } + + /** Counts how many of the [items] are in [favourites]. */ + fun count(items: List, favourites: Set, id: (T) -> String): Int = + items.count { id(it) in favourites } +} diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index c4179fb94c..71308ffad7 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -13,6 +13,7 @@ import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.FavouritesManager +import app.gamenative.data.FavouritesUtils import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.data.gog.GogRecommendationsRepository @@ -968,7 +969,7 @@ class LibraryViewModel @Inject constructor( if (includeAmazon && !steamCollectionSelected) addAll(amazonEntries) }.let { entries -> if (currentTab == app.gamenative.ui.enums.LibraryTab.FAVOURITES) { - entries.filter { it.item.appId in favouriteIds } + FavouritesUtils.filter(entries, favouriteIds) { it.item.appId } } else { entries } @@ -1059,13 +1060,16 @@ class LibraryViewModel @Inject constructor( amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0, localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0, steamCollectionCounts = steamCollectionCounts, - favouritesCount = buildList { - if (currentState.showSteamInLibrary) addAll(steamEntries) - if (currentState.showCustomGamesInLibrary) addAll(customEntries) - if (currentState.showGOGInLibrary && GOGService.hasStoredCredentials(context)) addAll(gogEntries) - if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) addAll(epicEntries) - if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) - }.count { it.item.appId in favouriteIds }, + favouritesCount = FavouritesUtils.count( + buildList { + if (currentState.showSteamInLibrary) addAll(steamEntries) + if (currentState.showCustomGamesInLibrary) addAll(customEntries) + if (currentState.showGOGInLibrary && GOGService.hasStoredCredentials(context)) addAll(gogEntries) + if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) addAll(epicEntries) + if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) + }, + favouriteIds, + ) { it.item.appId }, ) } } diff --git a/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt b/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt new file mode 100644 index 0000000000..173e6d334e --- /dev/null +++ b/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt @@ -0,0 +1,87 @@ +package app.gamenative.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FavouritesUtilsTest { + + private data class Game(val appId: String, val name: String) + + @Test + fun apply_addsAppIdWhenFavouriteIsTrue() { + val result = FavouritesUtils.apply(setOf("a"), "b", favourite = true) + + assertEquals(setOf("a", "b"), result) + } + + @Test + fun apply_removesAppIdWhenFavouriteIsFalse() { + val result = FavouritesUtils.apply(setOf("a", "b"), "b", favourite = false) + + assertEquals(setOf("a"), result) + } + + @Test + fun apply_isIdempotentWhenAlreadyInDesiredState() { + val current = setOf("a") + + assertEquals(current, FavouritesUtils.apply(current, "a", favourite = true)) + assertEquals(current, FavouritesUtils.apply(current, "b", favourite = false)) + } + + @Test + fun toggle_addsWhenMissingAndRemovesWhenPresent() { + val added = FavouritesUtils.toggle(setOf("a"), "b") + assertEquals(setOf("a", "b"), added) + + val removed = FavouritesUtils.toggle(added, "b") + assertEquals(setOf("a"), removed) + } + + @Test + fun filter_keepsOnlyFavouritesAndPreservesOrder() { + val games = listOf( + Game(appId = "1", name = "First"), + Game(appId = "2", name = "Second"), + Game(appId = "3", name = "Third"), + ) + + val result = FavouritesUtils.filter(games, favourites = setOf("3", "1")) { it.appId } + + assertEquals(listOf("First", "Third"), result.map { it.name }) + } + + @Test + fun filter_returnsEmptyWhenNothingIsFavourited() { + val games = listOf(Game(appId = "1", name = "First")) + + val result = FavouritesUtils.filter(games, favourites = emptySet()) { it.appId } + + assertTrue(result.isEmpty()) + } + + @Test + fun count_matchesTheNumberOfFavouritedItems() { + val games = listOf( + Game(appId = "1", name = "First"), + Game(appId = "2", name = "Second"), + Game(appId = "3", name = "Third"), + ) + + val count = FavouritesUtils.count(games, favourites = setOf("1", "3", "missing")) { it.appId } + + assertEquals(2, count) + } + + @Test + fun count_ignoresFavouriteIdsThatAreNotInTheList() { + val games = listOf(Game(appId = "1", name = "First")) + + val count = FavouritesUtils.count(games, favourites = setOf("99")) { it.appId } + + assertEquals(0, count) + assertFalse(count == games.size) + } +} From 33ff3757f411c5eb5f1c0644836e2d3fae052858 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 18:03:48 -0400 Subject: [PATCH 03/18] Make favourite updates atomic in FavouritesManager Use MutableStateFlow.updateAndGet so the read-modify-write happens in one step. This avoids a lost update if setFavourite is ever called from more than one thread, and we only write to PrefManager when the set actually changed. --- .../java/app/gamenative/data/FavouritesManager.kt | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavouritesManager.kt index 9bb2893076..c4dfdba156 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavouritesManager.kt @@ -4,6 +4,7 @@ import app.gamenative.PrefManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.updateAndGet /** * Keeps track of which games the user has marked as favourite. @@ -25,11 +26,11 @@ object FavouritesManager { fun toggle(appId: String) = setFavourite(appId, !isFavourite(appId)) fun setFavourite(appId: String, favourite: Boolean) { - val current = _favourites.value - val updated = FavouritesUtils.apply(current, appId, favourite) - if (updated == current) return - - _favourites.value = updated - PrefManager.favouriteAppIds = updated + val updated = _favourites.updateAndGet { current -> + FavouritesUtils.apply(current, appId, favourite) + } + if (PrefManager.favouriteAppIds != updated) { + PrefManager.favouriteAppIds = updated + } } } From 0db1c687b69bee2e04bbe1bba86bffa65c36598a Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 19:15:56 -0400 Subject: [PATCH 04/18] Load favourites off the main thread in FavouritesManager Building the singleton no longer does a synchronous DataStore read in its initializer, so the first card or detail menu that touches it does not block the UI thread on disk. The saved set now loads on Dispatchers.IO and fills the StateFlow when it returns. A small guard skips that load if the user has already toggled a favourite, so an early edit cannot be overwritten. --- .../app/gamenative/data/FavouritesManager.kt | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavouritesManager.kt index c4dfdba156..e74aaa320b 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavouritesManager.kt @@ -1,10 +1,16 @@ package app.gamenative.data import app.gamenative.PrefManager +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch /** * Keeps track of which games the user has marked as favourite. @@ -13,19 +19,39 @@ import kotlinx.coroutines.flow.updateAndGet * (Steam, GOG, Epic, Amazon and custom games) without needing an account. The current set is * exposed as a [StateFlow] so the library list and the game cards update as soon as it changes, * while [PrefManager] keeps the values on disk between sessions. + * + * The saved set is loaded off the main thread, so building this singleton (which happens the first + * time a card or the detail menu is drawn) never blocks the UI on a disk read. Until the load + * finishes the set is simply empty, then it fills in once the values come back. */ object FavouritesManager { - private val _favourites = MutableStateFlow(PrefManager.favouriteAppIds) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _favourites = MutableStateFlow>(emptySet()) /** The set of favourited app ids. Observe this to react to changes. */ val favourites: StateFlow> = _favourites.asStateFlow() + /** + * Whether the user has changed favourites already. It guards against the async load below + * overwriting an early toggle if someone stars a game before the saved set has been read. + */ + private val userHasEdited = AtomicBoolean(false) + + init { + scope.launch { + val stored = PrefManager.favouriteAppIds + _favourites.update { current -> if (userHasEdited.get()) current else stored } + } + } + fun isFavourite(appId: String): Boolean = _favourites.value.contains(appId) /** Adds the game if it is not a favourite yet, or removes it if it already is. */ fun toggle(appId: String) = setFavourite(appId, !isFavourite(appId)) fun setFavourite(appId: String, favourite: Boolean) { + userHasEdited.set(true) val updated = _favourites.updateAndGet { current -> FavouritesUtils.apply(current, appId, favourite) } From 71acc9480992634a224505ebd7579048cc65cf23 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 19:36:28 -0400 Subject: [PATCH 05/18] Replay early favourite edits on top of loaded set to prevent data loss The async load could drop previously saved favourites if the user starred a game before the disk read returned: the toggle applied on top of an empty set and then persisted. Pre-load edits are now recorded and replayed on top of the stored set once it loads, so nothing saved is lost. --- .../app/gamenative/data/FavouritesManager.kt | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavouritesManager.kt index e74aaa320b..4aeaaa15ed 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavouritesManager.kt @@ -1,14 +1,12 @@ package app.gamenative.data import app.gamenative.PrefManager -import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch @@ -22,7 +20,9 @@ import kotlinx.coroutines.launch * * The saved set is loaded off the main thread, so building this singleton (which happens the first * time a card or the detail menu is drawn) never blocks the UI on a disk read. Until the load - * finishes the set is simply empty, then it fills in once the values come back. + * finishes the set is simply empty. If the user stars a game in that short window, the edit is + * recorded and replayed on top of the loaded set, so an early toggle can never drop previously + * saved favourites. */ object FavouritesManager { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -32,16 +32,31 @@ object FavouritesManager { /** The set of favourited app ids. Observe this to react to changes. */ val favourites: StateFlow> = _favourites.asStateFlow() - /** - * Whether the user has changed favourites already. It guards against the async load below - * overwriting an early toggle if someone stars a game before the saved set has been read. - */ - private val userHasEdited = AtomicBoolean(false) + private val lock = Any() + private var loaded = false + + /** Edits made before the saved set finished loading, kept so they can be replayed on top of it. */ + private val pendingEdits = LinkedHashMap() init { scope.launch { val stored = PrefManager.favouriteAppIds - _favourites.update { current -> if (userHasEdited.get()) current else stored } + val merged: Set + val hadPendingEdits: Boolean + synchronized(lock) { + hadPendingEdits = pendingEdits.isNotEmpty() + var result = stored + for ((appId, favourite) in pendingEdits) { + result = FavouritesUtils.apply(result, appId, favourite) + } + pendingEdits.clear() + loaded = true + merged = result + _favourites.value = result + } + if (hadPendingEdits && PrefManager.favouriteAppIds != merged) { + PrefManager.favouriteAppIds = merged + } } } @@ -51,7 +66,15 @@ object FavouritesManager { fun toggle(appId: String) = setFavourite(appId, !isFavourite(appId)) fun setFavourite(appId: String, favourite: Boolean) { - userHasEdited.set(true) + synchronized(lock) { + if (!loaded) { + // The saved set has not loaded yet. Remember the intent and reflect it in the flow + // now for a responsive UI; persistence happens once the load merges it in. + pendingEdits[appId] = favourite + _favourites.value = FavouritesUtils.apply(_favourites.value, appId, favourite) + return + } + } val updated = _favourites.updateAndGet { current -> FavouritesUtils.apply(current, appId, favourite) } From 0f5f40d56112335252797c5a0e8453567aa353b9 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 19:39:06 -0400 Subject: [PATCH 06/18] Match favourites badge count to the Favourites tab contents The badge counted favourites using the user's per-source library visibility preferences, but the Favourites tab shows favourites from every source (gated only by credentials). If a source was hidden from the library the badge could undercount what the tab actually lists. The count now uses the same source logic as the tab. --- .../app/gamenative/ui/model/LibraryViewModel.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 71308ffad7..60a97f957f 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -1060,13 +1060,16 @@ class LibraryViewModel @Inject constructor( amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0, localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0, steamCollectionCounts = steamCollectionCounts, + // Count favourites across every source the Favourites tab actually shows (all + // sources, gated only by credentials), so the badge matches the tab contents + // even when a source is hidden from the library through user preferences. favouritesCount = FavouritesUtils.count( buildList { - if (currentState.showSteamInLibrary) addAll(steamEntries) - if (currentState.showCustomGamesInLibrary) addAll(customEntries) - if (currentState.showGOGInLibrary && GOGService.hasStoredCredentials(context)) addAll(gogEntries) - if (currentState.showEpicInLibrary && EpicService.hasStoredCredentials(context)) addAll(epicEntries) - if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) + addAll(steamEntries) + addAll(customEntries) + if (GOGService.hasStoredCredentials(context)) addAll(gogEntries) + if (EpicService.hasStoredCredentials(context)) addAll(epicEntries) + if (AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) }, favouriteIds, ) { it.item.appId }, From b9a0c66c6788c98d2abbd46b03cd4f6ca5106936 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 20:01:03 -0400 Subject: [PATCH 07/18] Persist favourites under the lock to close a TOCTOU race The post-load persistence write happened outside the lock and compared against a stale snapshot, so a toggle racing with the initial load could be silently dropped. The flow update and the persist now happen together under the same lock in both the loader and setFavourite, so whichever holds the lock last writes the latest set. --- .../app/gamenative/data/FavouritesManager.kt | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavouritesManager.kt index 4aeaaa15ed..f2b90ea373 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavouritesManager.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch /** @@ -41,21 +40,20 @@ object FavouritesManager { init { scope.launch { val stored = PrefManager.favouriteAppIds - val merged: Set - val hadPendingEdits: Boolean synchronized(lock) { - hadPendingEdits = pendingEdits.isNotEmpty() var result = stored for ((appId, favourite) in pendingEdits) { result = FavouritesUtils.apply(result, appId, favourite) } + val hadPendingEdits = pendingEdits.isNotEmpty() pendingEdits.clear() loaded = true - merged = result _favourites.value = result - } - if (hadPendingEdits && PrefManager.favouriteAppIds != merged) { - PrefManager.favouriteAppIds = merged + // Persist inside the lock so a concurrent toggle cannot be overwritten by a stale + // snapshot written after the lock is released. + if (hadPendingEdits) { + PrefManager.favouriteAppIds = result + } } } } @@ -67,19 +65,15 @@ object FavouritesManager { fun setFavourite(appId: String, favourite: Boolean) { synchronized(lock) { - if (!loaded) { - // The saved set has not loaded yet. Remember the intent and reflect it in the flow - // now for a responsive UI; persistence happens once the load merges it in. + val updated = FavouritesUtils.apply(_favourites.value, appId, favourite) + if (updated == _favourites.value) return + _favourites.value = updated + if (loaded) { + PrefManager.favouriteAppIds = updated + } else { + // Still loading: record the intent so the load replays it on top of the saved set. pendingEdits[appId] = favourite - _favourites.value = FavouritesUtils.apply(_favourites.value, appId, favourite) - return } } - val updated = _favourites.updateAndGet { current -> - FavouritesUtils.apply(current, appId, favourite) - } - if (PrefManager.favouriteAppIds != updated) { - PrefManager.favouriteAppIds = updated - } } } From 25cc20d78ff839dd974af6217bad008e1fe771ea Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 20:31:03 -0400 Subject: [PATCH 08/18] Observe favourites flow for detail-menu label Read the favourites set as Compose state in getFavouriteOption so the Add/Remove label reflects the current state when the menu is reopened after toggling, instead of a value captured once. --- .../gamenative/ui/screen/library/appscreen/BaseAppScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 3778b39268..242f318308 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 @@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.FileProvider import androidx.core.net.toUri import app.gamenative.PluviaApp @@ -723,7 +724,8 @@ abstract class BaseAppScreen { @Composable private fun getFavouriteOption(libraryItem: LibraryItem): AppMenuOption { - val isFavourite = FavouritesManager.isFavourite(libraryItem.appId) + val favourites by FavouritesManager.favourites.collectAsStateWithLifecycle() + val isFavourite = favourites.contains(libraryItem.appId) return AppMenuOption( optionType = if (isFavourite) { AppOptionMenuType.RemoveFromFavourites From 9e38abd2ac4dd750237c3c0d7bcae575d16009fa Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 20:44:15 -0400 Subject: [PATCH 09/18] Hide favourite star on recommended items in list view Recommended entries use synthetic ids that don't map to a real favourite, so hide the star in the list card to match the grid card which already skips it for recommended items. --- .../ui/screen/library/components/LibraryListCard.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt index 89da662d6a..7881ca89ee 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt @@ -199,10 +199,12 @@ internal fun ListViewCard( ) } - FavouriteStarButton( - appId = appInfo.appId, - iconSize = 22, - ) + if (!appInfo.isRecommended) { + FavouriteStarButton( + appId = appInfo.appId, + iconSize = 22, + ) + } } } } From 8533f278ecf1a174136ed8ef52a6d3f54036cf0e Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 21:06:57 -0400 Subject: [PATCH 10/18] Log decode failures for stored favourites Warn via Timber when the saved favourite ids can't be decoded instead of silently returning an empty set, so a corrupt value is easier to notice and diagnose. --- app/src/main/java/app/gamenative/PrefManager.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 517d643eb1..33782729d7 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1311,6 +1311,7 @@ object PrefManager { return try { Json.decodeFromString>(value) } catch (e: Exception) { + Timber.w(e, "Failed to decode favourite app ids; falling back to empty set") emptySet() } } From a669f553f192cfc6ff715a7c12cfd5417f88a353 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 21:26:01 -0400 Subject: [PATCH 11/18] Remove unused FavouritesUtils.toggle helper Nothing calls FavouritesUtils.toggle; the toggle behaviour lives in FavouritesManager, which builds on apply(). Drop the helper and its test to avoid dead code. --- app/src/main/java/app/gamenative/data/FavouritesUtils.kt | 4 ---- .../test/java/app/gamenative/data/FavouritesUtilsTest.kt | 9 --------- 2 files changed, 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/FavouritesUtils.kt b/app/src/main/java/app/gamenative/data/FavouritesUtils.kt index c0a7ab9639..736789cc49 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesUtils.kt +++ b/app/src/main/java/app/gamenative/data/FavouritesUtils.kt @@ -12,10 +12,6 @@ internal object FavouritesUtils { fun apply(current: Set, appId: String, favourite: Boolean): Set = if (favourite) current + appId else current - appId - /** Returns the favourites set with [appId] flipped on or off. */ - fun toggle(current: Set, appId: String): Set = - apply(current, appId, appId !in current) - /** Keeps only the [items] whose id (via [id]) is in [favourites], preserving order. */ fun filter(items: List, favourites: Set, id: (T) -> String): List = items.filter { id(it) in favourites } diff --git a/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt b/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt index 173e6d334e..b90759e116 100644 --- a/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt +++ b/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt @@ -31,15 +31,6 @@ class FavouritesUtilsTest { assertEquals(current, FavouritesUtils.apply(current, "b", favourite = false)) } - @Test - fun toggle_addsWhenMissingAndRemovesWhenPresent() { - val added = FavouritesUtils.toggle(setOf("a"), "b") - assertEquals(setOf("a", "b"), added) - - val removed = FavouritesUtils.toggle(added, "b") - assertEquals(setOf("a"), removed) - } - @Test fun filter_keepsOnlyFavouritesAndPreservesOrder() { val games = listOf( From f477a3a030a08d2a06824a800cc617593ee787e3 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Mon, 13 Jul 2026 22:11:38 -0400 Subject: [PATCH 12/18] Rename Favourite to Favorite across the feature Normalise the spelling to en-US: FavoritesManager/FavoritesUtils/ FavoriteStarButton, the PrefManager favoriteAppIds key, enum entries, the FAVORITES tab, and the English strings. Non-English translations keep their existing wording; only the string resource key names change. The stored preference key becomes favorite_app_ids (the feature is not yet released, so there is no saved data to migrate). --- .../main/java/app/gamenative/PrefManager.kt | 12 +++--- ...vouritesManager.kt => FavoritesManager.kt} | 42 +++++++++---------- .../app/gamenative/data/FavoritesUtils.kt | 22 ++++++++++ .../app/gamenative/data/FavouritesUtils.kt | 22 ---------- .../app/gamenative/ui/data/LibraryState.kt | 2 +- .../gamenative/ui/enums/AppOptionMenuType.kt | 4 +- .../app/gamenative/ui/enums/LibraryTab.kt | 4 +- .../gamenative/ui/model/LibraryViewModel.kt | 20 ++++----- .../ui/screen/library/LibraryScreen.kt | 2 +- .../screen/library/appscreen/BaseAppScreen.kt | 18 ++++---- ...iteStarButton.kt => FavoriteStarButton.kt} | 20 ++++----- .../library/components/GameOptionsPanel.kt | 8 ++-- .../library/components/LibraryGridCard.kt | 2 +- .../library/components/LibraryListCard.kt | 2 +- app/src/main/res/values-da/strings.xml | 11 ++--- app/src/main/res/values-de/strings.xml | 11 ++--- app/src/main/res/values-es/strings.xml | 11 ++--- app/src/main/res/values-fr/strings.xml | 11 ++--- app/src/main/res/values-it/strings.xml | 11 ++--- app/src/main/res/values-ja/strings.xml | 11 ++--- app/src/main/res/values-ko/strings.xml | 11 ++--- app/src/main/res/values-pl/strings.xml | 11 ++--- app/src/main/res/values-pt-rBR/strings.xml | 11 ++--- app/src/main/res/values-ro/strings.xml | 11 ++--- app/src/main/res/values-ru/strings.xml | 11 ++--- app/src/main/res/values-uk/strings.xml | 11 ++--- app/src/main/res/values-zh-rCN/strings.xml | 11 ++--- app/src/main/res/values-zh-rTW/strings.xml | 11 ++--- app/src/main/res/values/strings.xml | 12 +++--- ...itesUtilsTest.kt => FavoritesUtilsTest.kt} | 30 ++++++------- 30 files changed, 195 insertions(+), 181 deletions(-) rename app/src/main/java/app/gamenative/data/{FavouritesManager.kt => FavoritesManager.kt} (60%) create mode 100644 app/src/main/java/app/gamenative/data/FavoritesUtils.kt delete mode 100644 app/src/main/java/app/gamenative/data/FavouritesUtils.kt rename app/src/main/java/app/gamenative/ui/screen/library/components/{FavouriteStarButton.kt => FavoriteStarButton.kt} (67%) rename app/src/test/java/app/gamenative/data/{FavouritesUtilsTest.kt => FavoritesUtilsTest.kt} (53%) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 33782729d7..f3634f6222 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1302,21 +1302,21 @@ object PrefManager { setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value)) } - // Games the user has marked as favourite, stored as a set of LibraryItem.appId values so they + // Games the user has marked as favorite, stored as a set of LibraryItem.appId values so they // work across every source (Steam, GOG, Epic, Amazon, custom games) without needing an account. - private val FAVOURITE_APP_IDS = stringPreferencesKey("favourite_app_ids") - var favouriteAppIds: Set + private val FAVORITE_APP_IDS = stringPreferencesKey("favorite_app_ids") + var favoriteAppIds: Set get() { - val value = getPref(FAVOURITE_APP_IDS, "[]") + val value = getPref(FAVORITE_APP_IDS, "[]") return try { Json.decodeFromString>(value) } catch (e: Exception) { - Timber.w(e, "Failed to decode favourite app ids; falling back to empty set") + Timber.w(e, "Failed to decode favorite app ids; falling back to empty set") emptySet() } } set(value) { - setPref(FAVOURITE_APP_IDS, Json.encodeToString(value)) + setPref(FAVORITE_APP_IDS, Json.encodeToString(value)) } // Add new setting for Wine debug logging diff --git a/app/src/main/java/app/gamenative/data/FavouritesManager.kt b/app/src/main/java/app/gamenative/data/FavoritesManager.kt similarity index 60% rename from app/src/main/java/app/gamenative/data/FavouritesManager.kt rename to app/src/main/java/app/gamenative/data/FavoritesManager.kt index f2b90ea373..f1b042714e 100644 --- a/app/src/main/java/app/gamenative/data/FavouritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavoritesManager.kt @@ -10,9 +10,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** - * Keeps track of which games the user has marked as favourite. + * Keeps track of which games the user has marked as favorite. * - * Favourites are stored as a set of [LibraryItem.appId] values, so they work across every source + * Favorites are stored as a set of [LibraryItem.appId] values, so they work across every source * (Steam, GOG, Epic, Amazon and custom games) without needing an account. The current set is * exposed as a [StateFlow] so the library list and the game cards update as soon as it changes, * while [PrefManager] keeps the values on disk between sessions. @@ -21,15 +21,15 @@ import kotlinx.coroutines.launch * time a card or the detail menu is drawn) never blocks the UI on a disk read. Until the load * finishes the set is simply empty. If the user stars a game in that short window, the edit is * recorded and replayed on top of the loaded set, so an early toggle can never drop previously - * saved favourites. + * saved favorites. */ -object FavouritesManager { +object FavoritesManager { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val _favourites = MutableStateFlow>(emptySet()) + private val _favorites = MutableStateFlow>(emptySet()) - /** The set of favourited app ids. Observe this to react to changes. */ - val favourites: StateFlow> = _favourites.asStateFlow() + /** The set of favorited app ids. Observe this to react to changes. */ + val favorites: StateFlow> = _favorites.asStateFlow() private val lock = Any() private var loaded = false @@ -39,40 +39,40 @@ object FavouritesManager { init { scope.launch { - val stored = PrefManager.favouriteAppIds + val stored = PrefManager.favoriteAppIds synchronized(lock) { var result = stored - for ((appId, favourite) in pendingEdits) { - result = FavouritesUtils.apply(result, appId, favourite) + for ((appId, favorite) in pendingEdits) { + result = FavoritesUtils.apply(result, appId, favorite) } val hadPendingEdits = pendingEdits.isNotEmpty() pendingEdits.clear() loaded = true - _favourites.value = result + _favorites.value = result // Persist inside the lock so a concurrent toggle cannot be overwritten by a stale // snapshot written after the lock is released. if (hadPendingEdits) { - PrefManager.favouriteAppIds = result + PrefManager.favoriteAppIds = result } } } } - fun isFavourite(appId: String): Boolean = _favourites.value.contains(appId) + fun isFavorite(appId: String): Boolean = _favorites.value.contains(appId) - /** Adds the game if it is not a favourite yet, or removes it if it already is. */ - fun toggle(appId: String) = setFavourite(appId, !isFavourite(appId)) + /** Adds the game if it is not a favorite yet, or removes it if it already is. */ + fun toggle(appId: String) = setFavorite(appId, !isFavorite(appId)) - fun setFavourite(appId: String, favourite: Boolean) { + fun setFavorite(appId: String, favorite: Boolean) { synchronized(lock) { - val updated = FavouritesUtils.apply(_favourites.value, appId, favourite) - if (updated == _favourites.value) return - _favourites.value = updated + val updated = FavoritesUtils.apply(_favorites.value, appId, favorite) + if (updated == _favorites.value) return + _favorites.value = updated if (loaded) { - PrefManager.favouriteAppIds = updated + PrefManager.favoriteAppIds = updated } else { // Still loading: record the intent so the load replays it on top of the saved set. - pendingEdits[appId] = favourite + pendingEdits[appId] = favorite } } } diff --git a/app/src/main/java/app/gamenative/data/FavoritesUtils.kt b/app/src/main/java/app/gamenative/data/FavoritesUtils.kt new file mode 100644 index 0000000000..14ea952ec8 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavoritesUtils.kt @@ -0,0 +1,22 @@ +package app.gamenative.data + +/** + * Pure helpers for the favorites feature. + * + * The logic is kept here, separate from [FavoritesManager] and the library view model, so it can + * be reasoned about and unit tested on its own without touching storage or Android state. + */ +internal object FavoritesUtils { + + /** Returns the favorites set after adding or removing [appId]. */ + fun apply(current: Set, appId: String, favorite: Boolean): Set = + if (favorite) current + appId else current - appId + + /** Keeps only the [items] whose id (via [id]) is in [favorites], preserving order. */ + fun filter(items: List, favorites: Set, id: (T) -> String): List = + items.filter { id(it) in favorites } + + /** Counts how many of the [items] are in [favorites]. */ + fun count(items: List, favorites: Set, id: (T) -> String): Int = + items.count { id(it) in favorites } +} diff --git a/app/src/main/java/app/gamenative/data/FavouritesUtils.kt b/app/src/main/java/app/gamenative/data/FavouritesUtils.kt deleted file mode 100644 index 736789cc49..0000000000 --- a/app/src/main/java/app/gamenative/data/FavouritesUtils.kt +++ /dev/null @@ -1,22 +0,0 @@ -package app.gamenative.data - -/** - * Pure helpers for the favourites feature. - * - * The logic is kept here, separate from [FavouritesManager] and the library view model, so it can - * be reasoned about and unit tested on its own without touching storage or Android state. - */ -internal object FavouritesUtils { - - /** Returns the favourites set after adding or removing [appId]. */ - fun apply(current: Set, appId: String, favourite: Boolean): Set = - if (favourite) current + appId else current - appId - - /** Keeps only the [items] whose id (via [id]) is in [favourites], preserving order. */ - fun filter(items: List, favourites: Set, id: (T) -> String): List = - items.filter { id(it) in favourites } - - /** Counts how many of the [items] are in [favourites]. */ - fun count(items: List, favourites: Set, id: (T) -> String): Int = - items.count { id(it) in favourites } -} diff --git a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt index 1230183125..d22d91d4ef 100644 --- a/app/src/main/java/app/gamenative/ui/data/LibraryState.kt +++ b/app/src/main/java/app/gamenative/ui/data/LibraryState.kt @@ -71,7 +71,7 @@ data class LibraryState( val epicCount: Int = 0, val amazonCount: Int = 0, val localCount: Int = 0, - val favouritesCount: Int = 0, + val favoritesCount: Int = 0, ) /** diff --git a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt index 42a49dd73e..41b8344a7f 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt @@ -35,6 +35,6 @@ enum class AppOptionMenuType(@StringRes val title: Int) { ManageWorkshop(R.string.option_manage_workshop), ManageMods(R.string.option_manage_mods), ChangeBranch(R.string.change_branch), - AddToFavourites(R.string.option_add_to_favourites), - RemoveFromFavourites(R.string.option_remove_from_favourites), + AddToFavorites(R.string.option_add_to_favorites), + RemoveFromFavorites(R.string.option_remove_from_favorites), } diff --git a/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt index bd0e5edf68..0fc1b8ef32 100644 --- a/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt +++ b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt @@ -37,8 +37,8 @@ enum class LibraryTab( showAmazon = true, installedOnly = false, ), - FAVOURITES( - labelResId = R.string.tab_favourites, + FAVORITES( + labelResId = R.string.tab_favorites, showCustom = true, showSteam = true, showGoG = true, diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 60a97f957f..9fb86c578f 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -12,8 +12,8 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus -import app.gamenative.data.FavouritesManager -import app.gamenative.data.FavouritesUtils +import app.gamenative.data.FavoritesManager +import app.gamenative.data.FavoritesUtils import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.data.gog.GogRecommendationsRepository @@ -180,10 +180,10 @@ class LibraryViewModel @Inject constructor( } } - // Re-filter whenever the set of favourite games changes, so the Favourites tab and the + // Re-filter whenever the set of favorite games changes, so the Favorites tab and the // tab badge stay in sync as the user stars or unstars games. viewModelScope.launch(Dispatchers.IO) { - FavouritesManager.favourites + FavoritesManager.favorites .drop(1) .collect { onFilterApps(paginationCurrentPage) @@ -959,7 +959,7 @@ class LibraryViewModel @Inject constructor( // sources can't match it — keep them out of the combined list (and their tab counts). val steamCollectionSelected = allowedSteamAppIds != null - val favouriteIds = FavouritesManager.favourites.value + val favoriteIds = FavoritesManager.favorites.value val combined = buildList { if (includeSteam) addAll(steamEntries) @@ -968,8 +968,8 @@ class LibraryViewModel @Inject constructor( if (includeEpic && !steamCollectionSelected) addAll(epicEntries) if (includeAmazon && !steamCollectionSelected) addAll(amazonEntries) }.let { entries -> - if (currentTab == app.gamenative.ui.enums.LibraryTab.FAVOURITES) { - FavouritesUtils.filter(entries, favouriteIds) { it.item.appId } + if (currentTab == app.gamenative.ui.enums.LibraryTab.FAVORITES) { + FavoritesUtils.filter(entries, favoriteIds) { it.item.appId } } else { entries } @@ -1060,10 +1060,10 @@ class LibraryViewModel @Inject constructor( amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0, localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0, steamCollectionCounts = steamCollectionCounts, - // Count favourites across every source the Favourites tab actually shows (all + // Count favorites across every source the Favorites tab actually shows (all // sources, gated only by credentials), so the badge matches the tab contents // even when a source is hidden from the library through user preferences. - favouritesCount = FavouritesUtils.count( + favoritesCount = FavoritesUtils.count( buildList { addAll(steamEntries) addAll(customEntries) @@ -1071,7 +1071,7 @@ class LibraryViewModel @Inject constructor( if (EpicService.hasStoredCredentials(context)) addAll(epicEntries) if (AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) }, - favouriteIds, + favoriteIds, ) { it.item.appId }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index e5e5b2374f..1f59447ad3 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -1048,7 +1048,7 @@ private fun LibraryScreenContent( currentTab = state.currentTab, tabCounts = mapOf( LibraryTab.ALL to state.allCount, - LibraryTab.FAVOURITES to state.favouritesCount, + LibraryTab.FAVORITES to state.favoritesCount, LibraryTab.STEAM to state.steamCount, LibraryTab.GOG to state.gogCount, LibraryTab.EPIC to state.epicCount, 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 242f318308..656200b80a 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 @@ -28,7 +28,7 @@ import androidx.core.net.toUri import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.GameSource -import app.gamenative.data.FavouritesManager +import app.gamenative.data.FavoritesManager import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent import app.gamenative.mods.ModContainerResolver @@ -723,17 +723,17 @@ abstract class BaseAppScreen { } @Composable - private fun getFavouriteOption(libraryItem: LibraryItem): AppMenuOption { - val favourites by FavouritesManager.favourites.collectAsStateWithLifecycle() - val isFavourite = favourites.contains(libraryItem.appId) + private fun getFavoriteOption(libraryItem: LibraryItem): AppMenuOption { + val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val isFavorite = favorites.contains(libraryItem.appId) return AppMenuOption( - optionType = if (isFavourite) { - AppOptionMenuType.RemoveFromFavourites + optionType = if (isFavorite) { + AppOptionMenuType.RemoveFromFavorites } else { - AppOptionMenuType.AddToFavourites + AppOptionMenuType.AddToFavorites }, onClick = { - FavouritesManager.toggle(libraryItem.appId) + FavoritesManager.toggle(libraryItem.appId) }, ) } @@ -978,7 +978,7 @@ abstract class BaseAppScreen { } // Always available options - menuOptions.add(getFavouriteOption(libraryItem)) + menuOptions.add(getFavoriteOption(libraryItem)) menuOptions.add(getSubmitFeedbackOption(context, libraryItem)) menuOptions.add(getGetSupportOption(context)) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt similarity index 67% rename from app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt rename to app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt index 95e14e6752..cefeb97050 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt @@ -15,40 +15,40 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.gamenative.R -import app.gamenative.data.FavouritesManager +import app.gamenative.data.FavoritesManager /** - * A star button that shows whether a game is a favourite and toggles it when tapped. + * A star button that shows whether a game is a favorite and toggles it when tapped. * - * It observes [FavouritesManager] directly, so it can be dropped onto any card or screen without + * It observes [FavoritesManager] directly, so it can be dropped onto any card or screen without * threading callbacks through the surrounding composables. * * @param onImage when true, the icon uses a light tint so it stays readable on top of cover art. */ @Composable -internal fun FavouriteStarButton( +internal fun FavoriteStarButton( appId: String, modifier: Modifier = Modifier, iconSize: Int = 20, onImage: Boolean = false, ) { - val favourites by FavouritesManager.favourites.collectAsStateWithLifecycle() - val isFavourite = appId in favourites + val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val isFavorite = appId in favorites val tint = when { - isFavourite -> MaterialTheme.colorScheme.primary + isFavorite -> MaterialTheme.colorScheme.primary onImage -> Color.White.copy(alpha = 0.85f) else -> MaterialTheme.colorScheme.onSurfaceVariant } IconButton( - onClick = { FavouritesManager.toggle(appId) }, + onClick = { FavoritesManager.toggle(appId) }, modifier = modifier, ) { Icon( - imageVector = if (isFavourite) Icons.Filled.Star else Icons.Filled.StarOutline, + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Filled.StarOutline, contentDescription = stringResource( - if (isFavourite) R.string.favourite_remove else R.string.favourite_add, + if (isFavorite) R.string.favorite_remove else R.string.favorite_add, ), tint = tint, modifier = Modifier.size(iconSize.dp), diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt index 5f55524ece..91b9a5a0cf 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt @@ -353,8 +353,8 @@ private fun getIconForOption(type: AppOptionMenuType): ImageVector { AppOptionMenuType.ManageWorkshop -> Icons.Default.Build AppOptionMenuType.ManageMods -> Icons.Default.Extension AppOptionMenuType.ChangeBranch -> Icons.AutoMirrored.Filled.CallSplit - AppOptionMenuType.AddToFavourites -> Icons.Filled.StarOutline - AppOptionMenuType.RemoveFromFavourites -> Icons.Filled.Star + AppOptionMenuType.AddToFavorites -> Icons.Filled.StarOutline + AppOptionMenuType.RemoveFromFavorites -> Icons.Filled.Star } } @@ -372,8 +372,8 @@ private fun groupOptions(options: List): Map quickActions.add(option) // Game Management diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt index 9400eef075..2c2844a7b1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt @@ -368,7 +368,7 @@ internal fun GridViewCard( iconSize = if (isCapsule) 14 else 12, ) - FavouriteStarButton( + FavoriteStarButton( appId = appInfo.appId, modifier = Modifier .align(Alignment.BottomEnd) diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt index 7881ca89ee..76d529b084 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt @@ -200,7 +200,7 @@ internal fun ListViewCard( } if (!appInfo.isRecommended) { - FavouriteStarButton( + FavoriteStarButton( appId = appInfo.appId, iconSize = 22, ) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b848819626..50805ce047 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1969,9 +1969,10 @@ Viser senest synkroniserede samlinger. Opret forbindelse for at opdatere. Ryd Kun standardsamlinger vises. Smarte samlinger understøttes ikke endnu. - Favoritter - Føj til favoritter - Fjern fra favoritter - Føj til favoritter - Fjern fra favoritter + + Favoritter + Føj til favoritter + Fjern fra favoritter + Føj til favoritter + Fjern fra favoritter diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7b8c5c61e8..02cbd9b9c8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2039,9 +2039,10 @@ Es werden die zuletzt synchronisierten Sammlungen angezeigt. Zum Aktualisieren verbinden. Löschen Es werden nur Standardsammlungen angezeigt. Intelligente Sammlungen werden noch nicht unterstützt. - Favoriten - Zu Favoriten hinzufügen - Aus Favoriten entfernen - Zu Favoriten hinzufügen - Aus Favoriten entfernen + + Favoriten + Zu Favoriten hinzufügen + Aus Favoriten entfernen + Zu Favoriten hinzufügen + Aus Favoriten entfernen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index e03ffb9f2b..211eb7ce29 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2097,9 +2097,10 @@ Mostrando las últimas colecciones sincronizadas. Conéctate para actualizar. Borrar Solo se muestran las colecciones estándar. Las colecciones inteligentes aún no son compatibles. - Favoritos - Añadir a favoritos - Quitar de favoritos - Añadir a favoritos - Quitar de favoritos + + Favoritos + Añadir a favoritos + Quitar de favoritos + Añadir a favoritos + Quitar de favoritos diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2ead34a02f..c7e93ad197 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2099,9 +2099,10 @@ Affichage des dernières collections synchronisées. Connectez-vous pour mettre à jour. Effacer Seules les collections standard sont affichées. Les collections dynamiques ne sont pas encore prises en charge. - Favoris - Ajouter aux favoris - Retirer des favoris - Ajouter aux favoris - Retirer des favoris + + Favoris + Ajouter aux favoris + Retirer des favoris + Ajouter aux favoris + Retirer des favoris diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 21d9c53d00..5f79f499e4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2090,9 +2090,10 @@ Visualizzazione delle ultime raccolte sincronizzate. Connettiti per aggiornare. Cancella Vengono mostrate solo le raccolte standard. Le raccolte intelligenti non sono ancora supportate. - Preferiti - Aggiungi ai preferiti - Rimuovi dai preferiti - Aggiungi ai preferiti - Rimuovi dai preferiti + + Preferiti + Aggiungi ai preferiti + Rimuovi dai preferiti + Aggiungi ai preferiti + Rimuovi dai preferiti diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 213f065bfb..6664176cf1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2056,9 +2056,10 @@ 最後に同期されたコレクションを表示しています。更新するには接続してください。 クリア 標準コレクションのみ表示されます。スマートコレクションはまだ対応していません。 - お気に入り - お気に入りに追加 - お気に入りから削除 - お気に入りに追加 - お気に入りから削除 + + お気に入り + お気に入りに追加 + お気に入りから削除 + お気に入りに追加 + お気に入りから削除 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e2b646a4e9..dab0f89ecd 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2097,9 +2097,10 @@ 마지막으로 동기화된 컬렉션을 표시하고 있습니다. 업데이트하려면 연결하세요. 지우기 표준 컬렉션만 표시됩니다. 스마트 컬렉션은 아직 지원되지 않습니다. - 즐겨찾기 - 즐겨찾기에 추가 - 즐겨찾기에서 제거 - 즐겨찾기에 추가 - 즐겨찾기에서 제거 + + 즐겨찾기 + 즐겨찾기에 추가 + 즐겨찾기에서 제거 + 즐겨찾기에 추가 + 즐겨찾기에서 제거 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ade3f3c99f..8ce6bce38a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2097,9 +2097,10 @@ Wyświetlanie ostatnio zsynchronizowanych kolekcji. Połącz się, aby zaktualizować. Wyczyść Wyświetlane są tylko standardowe kolekcje. Kolekcje inteligentne nie są jeszcze obsługiwane. - Ulubione - Dodaj do ulubionych - Usuń z ulubionych - Dodaj do ulubionych - Usuń z ulubionych + + Ulubione + Dodaj do ulubionych + Usuń z ulubionych + Dodaj do ulubionych + Usuń z ulubionych diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e1b08d04e4..d62d7a9f9e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1969,9 +1969,10 @@ Exibindo as últimas coleções sincronizadas. Conecte-se para atualizar. Limpar Apenas coleções padrão são exibidas. Coleções inteligentes ainda não são compatíveis. - Favoritos - Adicionar aos favoritos - Remover dos favoritos - Adicionar aos favoritos - Remover dos favoritos + + Favoritos + Adicionar aos favoritos + Remover dos favoritos + Adicionar aos favoritos + Remover dos favoritos diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 2ef6a1f1a8..9d841a2283 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2100,9 +2100,10 @@ Se afișează ultimele colecții sincronizate. Conectează-te pentru a actualiza. Șterge Sunt afișate doar colecțiile standard. Colecțiile inteligente nu sunt încă acceptate. - Favorite - Adaugă la favorite - Elimină de la favorite - Adaugă la favorite - Elimină de la favorite + + Favorite + Adaugă la favorite + Elimină de la favorite + Adaugă la favorite + Elimină de la favorite diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index fff9f02bdb..cbfb148a41 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2025,9 +2025,10 @@ https://gamenative.app Показаны последние синхронизированные коллекции. Подключитесь для обновления. Очистить Показаны только обычные коллекции. Умные коллекции пока не поддерживаются. - Избранное - Добавить в избранное - Удалить из избранного - Добавить в избранное - Удалить из избранного + + Избранное + Добавить в избранное + Удалить из избранного + Добавить в избранное + Удалить из избранного diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 049e1cb269..1f97115863 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2093,9 +2093,10 @@ Показано останні синхронізовані колекції. Підключіться, щоб оновити. Очистити Показано лише звичайні колекції. Розумні колекції ще не підтримуються. - Вибране - Додати до вибраного - Видалити з вибраного - Додати до вибраного - Видалити з вибраного + + Вибране + Додати до вибраного + Видалити з вибраного + Додати до вибраного + Видалити з вибраного diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c095d8d1b2..8626f592f3 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2117,9 +2117,10 @@ 正在显示上次同步的收藏集。请连接以更新。 清除 仅显示标准收藏集。智能收藏集尚不受支持。 - 收藏 - 添加到收藏 - 从收藏中移除 - 添加到收藏 - 从收藏中移除 + + 收藏 + 添加到收藏 + 从收藏中移除 + 添加到收藏 + 从收藏中移除 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 774d080708..383963d672 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2108,9 +2108,10 @@ 正在顯示上次同步的收藏。請連線以更新。 清除 僅顯示標準收藏。智慧型收藏尚不支援。 - 收藏 - 加入收藏 - 從收藏中移除 - 加入收藏 - 從收藏中移除 + + 收藏 + 加入收藏 + 從收藏中移除 + 加入收藏 + 從收藏中移除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f78fa9095d..b452630c16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - + GameNative User Login Two Factor @@ -109,7 +109,7 @@ Recommended Add or play some games to get GOG recommendations based on your library. All - Favourites + Favorites Steam GOG Epic @@ -1623,10 +1623,10 @@ Reset container Get support Submit feedback - Add to favourites - Remove from favourites - Add to favourites - Remove from favourites + Add to favorites + Remove from favorites + Add to favorites + Remove from favorites Reset DRM Use known config Import saves diff --git a/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt similarity index 53% rename from app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt rename to app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt index b90759e116..31814fb5e9 100644 --- a/app/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt +++ b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt @@ -5,20 +5,20 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class FavouritesUtilsTest { +class FavoritesUtilsTest { private data class Game(val appId: String, val name: String) @Test - fun apply_addsAppIdWhenFavouriteIsTrue() { - val result = FavouritesUtils.apply(setOf("a"), "b", favourite = true) + fun apply_addsAppIdWhenFavoriteIsTrue() { + val result = FavoritesUtils.apply(setOf("a"), "b", favorite = true) assertEquals(setOf("a", "b"), result) } @Test - fun apply_removesAppIdWhenFavouriteIsFalse() { - val result = FavouritesUtils.apply(setOf("a", "b"), "b", favourite = false) + fun apply_removesAppIdWhenFavoriteIsFalse() { + val result = FavoritesUtils.apply(setOf("a", "b"), "b", favorite = false) assertEquals(setOf("a"), result) } @@ -27,50 +27,50 @@ class FavouritesUtilsTest { fun apply_isIdempotentWhenAlreadyInDesiredState() { val current = setOf("a") - assertEquals(current, FavouritesUtils.apply(current, "a", favourite = true)) - assertEquals(current, FavouritesUtils.apply(current, "b", favourite = false)) + assertEquals(current, FavoritesUtils.apply(current, "a", favorite = true)) + assertEquals(current, FavoritesUtils.apply(current, "b", favorite = false)) } @Test - fun filter_keepsOnlyFavouritesAndPreservesOrder() { + fun filter_keepsOnlyFavoritesAndPreservesOrder() { val games = listOf( Game(appId = "1", name = "First"), Game(appId = "2", name = "Second"), Game(appId = "3", name = "Third"), ) - val result = FavouritesUtils.filter(games, favourites = setOf("3", "1")) { it.appId } + val result = FavoritesUtils.filter(games, favorites = setOf("3", "1")) { it.appId } assertEquals(listOf("First", "Third"), result.map { it.name }) } @Test - fun filter_returnsEmptyWhenNothingIsFavourited() { + fun filter_returnsEmptyWhenNothingIsFavorited() { val games = listOf(Game(appId = "1", name = "First")) - val result = FavouritesUtils.filter(games, favourites = emptySet()) { it.appId } + val result = FavoritesUtils.filter(games, favorites = emptySet()) { it.appId } assertTrue(result.isEmpty()) } @Test - fun count_matchesTheNumberOfFavouritedItems() { + fun count_matchesTheNumberOfFavoritedItems() { val games = listOf( Game(appId = "1", name = "First"), Game(appId = "2", name = "Second"), Game(appId = "3", name = "Third"), ) - val count = FavouritesUtils.count(games, favourites = setOf("1", "3", "missing")) { it.appId } + val count = FavoritesUtils.count(games, favorites = setOf("1", "3", "missing")) { it.appId } assertEquals(2, count) } @Test - fun count_ignoresFavouriteIdsThatAreNotInTheList() { + fun count_ignoresFavoriteIdsThatAreNotInTheList() { val games = listOf(Game(appId = "1", name = "First")) - val count = FavouritesUtils.count(games, favourites = setOf("99")) { it.appId } + val count = FavoritesUtils.count(games, favorites = setOf("99")) { it.appId } assertEquals(0, count) assertFalse(count == games.size) From 8b3f5eb17b03d236fef53b6ad4113aa500273787 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Wed, 22 Jul 2026 17:16:02 -0400 Subject: [PATCH 13/18] Add Favorites empty state and harden favorites UX - Add a dedicated Favorites tab empty state with a "no favorites yet" variant (with a CTA back to All) and a filtered/unavailable variant, localized across all 14 supported languages. - Expose FavoritesManager.loaded so the UI distinguishes "loaded empty" from "not yet loaded", publishing the loaded set before flipping the flag to avoid an empty-state/star flash. - Only recompute the badge (not a full library re-filter) when favorites change while off the Favorites tab, removing needless rebuild/flicker. - Clamp pagination to the valid range so removing favorites can't leave the pager on a page past the last one. - Extract FavoritesUtils.countPresent and add unit tests. --- .../app/gamenative/data/FavoritesManager.kt | 17 +++- .../app/gamenative/data/FavoritesUtils.kt | 4 + .../gamenative/ui/model/LibraryViewModel.kt | 59 ++++++++----- .../ui/screen/library/LibraryScreen.kt | 27 ++++++ .../components/LibraryFavoritesEmptyState.kt | 82 +++++++++++++++++++ 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 | 5 ++ app/src/main/res/values-ko/strings.xml | 5 ++ app/src/main/res/values-pl/strings.xml | 5 ++ app/src/main/res/values-pt-rBR/strings.xml | 5 ++ app/src/main/res/values-ro/strings.xml | 5 ++ app/src/main/res/values-ru/strings.xml | 5 ++ app/src/main/res/values-uk/strings.xml | 5 ++ app/src/main/res/values-zh-rCN/strings.xml | 5 ++ app/src/main/res/values-zh-rTW/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 5 ++ .../app/gamenative/data/FavoritesUtilsTest.kt | 42 ++++++++++ 21 files changed, 282 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/screen/library/components/LibraryFavoritesEmptyState.kt diff --git a/app/src/main/java/app/gamenative/data/FavoritesManager.kt b/app/src/main/java/app/gamenative/data/FavoritesManager.kt index f1b042714e..31f726da80 100644 --- a/app/src/main/java/app/gamenative/data/FavoritesManager.kt +++ b/app/src/main/java/app/gamenative/data/FavoritesManager.kt @@ -31,8 +31,16 @@ object FavoritesManager { /** The set of favorited app ids. Observe this to react to changes. */ val favorites: StateFlow> = _favorites.asStateFlow() + private val _loaded = MutableStateFlow(false) + + /** + * Whether the saved set has finished loading from disk. Observe this to tell a genuinely empty + * favorites set apart from one that simply hasn't loaded yet, so the UI doesn't flash an + * "empty" state before the stored favorites arrive. + */ + val loaded: StateFlow = _loaded.asStateFlow() + private val lock = Any() - private var loaded = false /** Edits made before the saved set finished loading, kept so they can be replayed on top of it. */ private val pendingEdits = LinkedHashMap() @@ -47,8 +55,11 @@ object FavoritesManager { } val hadPendingEdits = pendingEdits.isNotEmpty() pendingEdits.clear() - loaded = true + // Publish the loaded set before flipping the loaded flag, so an observer that reacts + // to `loaded` never sees `true` while `favorites` is still the initial empty set + // (which would briefly render the "no favorites yet" empty state). _favorites.value = result + _loaded.value = true // Persist inside the lock so a concurrent toggle cannot be overwritten by a stale // snapshot written after the lock is released. if (hadPendingEdits) { @@ -68,7 +79,7 @@ object FavoritesManager { val updated = FavoritesUtils.apply(_favorites.value, appId, favorite) if (updated == _favorites.value) return _favorites.value = updated - if (loaded) { + if (_loaded.value) { PrefManager.favoriteAppIds = updated } else { // Still loading: record the intent so the load replays it on top of the saved set. diff --git a/app/src/main/java/app/gamenative/data/FavoritesUtils.kt b/app/src/main/java/app/gamenative/data/FavoritesUtils.kt index 14ea952ec8..3d2981d681 100644 --- a/app/src/main/java/app/gamenative/data/FavoritesUtils.kt +++ b/app/src/main/java/app/gamenative/data/FavoritesUtils.kt @@ -19,4 +19,8 @@ internal object FavoritesUtils { /** Counts how many of the [items] are in [favorites]. */ fun count(items: List, favorites: Set, id: (T) -> String): Int = items.count { id(it) in favorites } + + /** Counts how many [favorites] are present in [eligibleIds] (the intersection size). */ + fun countPresent(favorites: Set, eligibleIds: Set): Int = + favorites.count { it in eligibleIds } } diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 9fb86c578f..a1a92e038c 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -116,6 +116,11 @@ class LibraryViewModel @Inject constructor( @Volatile private var paginationCurrentPage: Int = 0 @Volatile private var lastPageInCurrentFilter: Int = 0 + // App ids across every source the Favorites tab shows (gated only by credentials), cached from + // the last filter pass so a favorite toggle can update the badge count without rebuilding the + // whole library list when the user isn't on the Favorites tab. + @Volatile private var favoriteEligibleAppIds: Set = emptySet() + // Complete and unfiltered app list private var appList: List = emptyList() private var gogGameList: List = emptyList() @@ -180,13 +185,20 @@ class LibraryViewModel @Inject constructor( } } - // Re-filter whenever the set of favorite games changes, so the Favorites tab and the - // tab badge stay in sync as the user stars or unstars games. + // Keep the Favorites tab and its badge in sync as the user stars or unstars games. When the + // user is actually viewing the Favorites tab we rebuild the list so its contents change; + // otherwise only the badge count can change, so we update that cheaply instead of running a + // full (and visibly loading) re-filter of the entire library. viewModelScope.launch(Dispatchers.IO) { FavoritesManager.favorites .drop(1) - .collect { - onFilterApps(paginationCurrentPage) + .collect { favorites -> + if (_state.value.currentTab == LibraryTab.FAVORITES) { + onFilterApps(paginationCurrentPage) + } else { + val count = FavoritesUtils.countPresent(favorites, favoriteEligibleAppIds) + _state.update { it.copy(favoritesCount = count) } + } } } @@ -982,11 +994,15 @@ class LibraryViewModel @Inject constructor( // Determine how many pages and slice the list for incremental loading val pageSize = PrefManager.itemsPerPage - // Update internal pagination state - paginationCurrentPage = paginationPage lastPageInCurrentFilter = if (totalFound == 0) 0 else (totalFound - 1) / pageSize + // Clamp the requested page to the valid range. Removing favorites (or any other filter + // change) can shrink the list so the previously shown page no longer exists; without + // this the pager could report a current page past the last one. + val clampedPage = paginationPage.coerceIn(0, lastPageInCurrentFilter) + // Update internal pagination state + paginationCurrentPage = clampedPage // Calculate how many items to show: (pagesLoaded * pageSize) - val endIndex = min((paginationPage + 1) * pageSize, totalFound) + val endIndex = min((clampedPage + 1) * pageSize, totalFound) var pagedList = combined.take(endIndex) // Prepend the hero (featured > recommendation) as first item on ALL tab when @@ -1040,10 +1056,23 @@ class LibraryViewModel @Inject constructor( // Fetch compatibility for current page games fetchCompatibilityForPage(pagedList.map { it.name }) + // App ids across every source the Favorites tab shows (all sources, gated only by + // credentials). Cache it so a later favorite toggle can recount the badge cheaply, and + // use it here so the badge matches the tab contents even when a source is hidden from + // the library through user preferences. + val favoriteEligible = buildList { + addAll(steamEntries) + addAll(customEntries) + if (GOGService.hasStoredCredentials(context)) addAll(gogEntries) + if (EpicService.hasStoredCredentials(context)) addAll(epicEntries) + if (AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) + }.mapTo(mutableSetOf()) { it.item.appId } + favoriteEligibleAppIds = favoriteEligible + _state.update { it.copy( appInfoList = pagedList, - currentPaginationPage = paginationPage + 1, // visual display is not 0 indexed + currentPaginationPage = clampedPage + 1, // visual display is not 0 indexed lastPaginationPage = lastPageInCurrentFilter + 1, totalAppsInFilter = totalFound, isLoading = false, // Loading complete @@ -1060,19 +1089,7 @@ class LibraryViewModel @Inject constructor( amazonCount = if (currentState.showAmazonInLibrary && AmazonService.hasStoredCredentials(context)) amazonEntries.size else 0, localCount = if (currentState.showCustomGamesInLibrary) customEntries.size else 0, steamCollectionCounts = steamCollectionCounts, - // Count favorites across every source the Favorites tab actually shows (all - // sources, gated only by credentials), so the badge matches the tab contents - // even when a source is hidden from the library through user preferences. - favoritesCount = FavoritesUtils.count( - buildList { - addAll(steamEntries) - addAll(customEntries) - if (GOGService.hasStoredCredentials(context)) addAll(gogEntries) - if (EpicService.hasStoredCredentials(context)) addAll(epicEntries) - if (AmazonService.hasStoredCredentials(context)) addAll(amazonEntries) - }, - favoriteIds, - ) { it.item.appId }, + favoritesCount = FavoritesUtils.countPresent(favoriteIds, favoriteEligible), ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 1f59447ad3..68cefc527d 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -74,6 +74,7 @@ import app.gamenative.PrefManager import app.gamenative.PluviaApp import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus +import app.gamenative.data.FavoritesManager import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent @@ -94,6 +95,7 @@ import app.gamenative.service.SteamService import app.gamenative.ui.screen.library.components.LibraryCarouselPane import app.gamenative.ui.screen.library.components.LibraryDetailPane import app.gamenative.ui.screen.library.components.LibraryListPane +import app.gamenative.ui.screen.library.components.LibraryFavoritesEmptyState import app.gamenative.ui.screen.library.components.RecommendationDisclosureDialog import app.gamenative.ui.screen.library.components.LibraryOptionsPanel import app.gamenative.ui.screen.library.components.LibrarySearchBar @@ -949,6 +951,13 @@ private fun LibraryScreenContent( LibraryTab.LOCAL -> PrefManager.customGamesCount == 0 else -> false } + // Favorites tab has its own empty state. Only show it once favorites have loaded and + // the list has settled, so a genuinely empty tab is explained instead of flashing a + // blank screen (or the empty message before stored favorites arrive). + val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val favoritesLoaded by FavoritesManager.loaded.collectAsStateWithLifecycle() + val showFavoritesEmptyState = state.currentTab == LibraryTab.FAVORITES && + favoritesLoaded && !state.isLoading && state.appInfoList.isEmpty() if (showEmptyStateSplash) { val (messageResId, buttonResId, onAction) = when (state.currentTab) { LibraryTab.STEAM -> Triple( @@ -984,6 +993,24 @@ private fun LibraryScreenContent( onSignInClick = onAction, modifier = Modifier.fillMaxSize(), ) + } else if (showFavoritesEmptyState) { + if (favorites.isEmpty()) { + LibraryFavoritesEmptyState( + titleResId = R.string.favorites_empty_title, + messageResId = R.string.favorites_empty_message, + actionLabelResId = R.string.favorites_empty_action, + onAction = { onTabChanged(LibraryTab.ALL) }, + modifier = Modifier.fillMaxSize(), + ) + } else { + // Favorites exist but none are visible — filtered out by the current search + // or unavailable (source logged out / game removed). + LibraryFavoritesEmptyState( + titleResId = R.string.favorites_empty_filtered_title, + messageResId = R.string.favorites_empty_filtered_message, + modifier = Modifier.fillMaxSize(), + ) + } } else { // Library list (content scrolls behind tab bar) if (currentPaneType == PaneType.CAROUSEL) { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryFavoritesEmptyState.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryFavoritesEmptyState.kt new file mode 100644 index 0000000000..e53312017b --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryFavoritesEmptyState.kt @@ -0,0 +1,82 @@ +package app.gamenative.ui.screen.library.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.StarOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.gamenative.R + +/** + * Empty state shown on the Favorites tab. Explains how favorites work and, when the user simply + * hasn't added any yet, offers a way back to the full library so the tab never reads as broken or + * still loading. + * + * @param titleResId headline shown in bold. + * @param messageResId supporting line explaining what to do (or why nothing is shown). + * @param actionLabelResId optional button label; when null no button is shown. + * @param onAction invoked when the optional button is pressed. + */ +@Composable +internal fun LibraryFavoritesEmptyState( + @StringRes titleResId: Int, + @StringRes messageResId: Int, + modifier: Modifier = Modifier, + @StringRes actionLabelResId: Int? = null, + onAction: (() -> Unit)? = null, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.StarOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(titleResId), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(messageResId), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + if (actionLabelResId != null && onAction != null) { + Spacer(modifier = Modifier.height(24.dp)) + OutlinedButton( + onClick = onAction, + modifier = Modifier.padding(horizontal = 24.dp), + ) { + Text(stringResource(actionLabelResId)) + } + } + } +} diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 50805ce047..8123d1e90c 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1971,6 +1971,11 @@ Kun standardsamlinger vises. Smarte samlinger understøttes ikke endnu. Favoritter + Ingen favoritter endnu + Tryk på stjernen på et spil for at tilføje det her for hurtig adgang. + Gennemse alle spil + Ingen favoritter at vise + Ingen favoritspil matcher den aktuelle søgning eller filtre. Føj til favoritter Fjern fra favoritter Føj til favoritter diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 02cbd9b9c8..5d10859f7e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2041,6 +2041,11 @@ Es werden nur Standardsammlungen angezeigt. Intelligente Sammlungen werden noch nicht unterstützt. Favoriten + Noch keine Favoriten + Tippe bei einem Spiel auf den Stern, um es für den schnellen Zugriff hier hinzuzufügen. + Alle Spiele durchsuchen + Keine Favoriten anzuzeigen + Keine Favoriten-Spiele entsprechen der aktuellen Suche oder den Filtern. Zu Favoriten hinzufügen Aus Favoriten entfernen Zu Favoriten hinzufügen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 211eb7ce29..8040da2281 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2099,6 +2099,11 @@ Solo se muestran las colecciones estándar. Las colecciones inteligentes aún no son compatibles. Favoritos + Aún no hay favoritos + Toca la estrella de cualquier juego para añadirlo aquí y acceder rápidamente. + Ver todos los juegos + No hay favoritos para mostrar + Ningún juego favorito coincide con la búsqueda o los filtros actuales. Añadir a favoritos Quitar de favoritos Añadir a favoritos diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c7e93ad197..20cfe68c4b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2101,6 +2101,11 @@ Seules les collections standard sont affichées. Les collections dynamiques ne sont pas encore prises en charge. Favoris + Aucun favori pour l’instant + Appuyez sur l’étoile d’un jeu pour l’ajouter ici et y accéder rapidement. + Parcourir tous les jeux + Aucun favori à afficher + Aucun jeu favori ne correspond à la recherche ou aux filtres actuels. Ajouter aux favoris Retirer des favoris Ajouter aux favoris diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5f79f499e4..875348c17b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2092,6 +2092,11 @@ Vengono mostrate solo le raccolte standard. Le raccolte intelligenti non sono ancora supportate. Preferiti + Ancora nessun preferito + Tocca la stella su un gioco per aggiungerlo qui e accedervi rapidamente. + Sfoglia tutti i giochi + Nessun preferito da mostrare + Nessun gioco preferito corrisponde alla ricerca o ai filtri attuali. Aggiungi ai preferiti Rimuovi dai preferiti Aggiungi ai preferiti diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 6664176cf1..29ce80c442 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2058,6 +2058,11 @@ 標準コレクションのみ表示されます。スマートコレクションはまだ対応していません。 お気に入り + お気に入りはまだありません + ゲームの星アイコンをタップすると、ここに追加してすぐにアクセスできます。 + すべてのゲームを見る + 表示するお気に入りがありません + 現在の検索やフィルターに一致するお気に入りのゲームはありません。 お気に入りに追加 お気に入りから削除 お気に入りに追加 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index dab0f89ecd..8c7978f408 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2099,6 +2099,11 @@ 표준 컬렉션만 표시됩니다. 스마트 컬렉션은 아직 지원되지 않습니다. 즐겨찾기 + 아직 즐겨찾기가 없습니다 + 게임의 별 아이콘을 눌러 여기에 추가하고 빠르게 이용하세요. + 모든 게임 보기 + 표시할 즐겨찾기가 없습니다 + 현재 검색어나 필터와 일치하는 즐겨찾기 게임이 없습니다. 즐겨찾기에 추가 즐겨찾기에서 제거 즐겨찾기에 추가 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 8ce6bce38a..02475008c6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2099,6 +2099,11 @@ Wyświetlane są tylko standardowe kolekcje. Kolekcje inteligentne nie są jeszcze obsługiwane. Ulubione + Brak ulubionych + Dotknij gwiazdki przy grze, aby dodać ją tutaj i mieć szybki dostęp. + Przeglądaj wszystkie gry + Brak ulubionych do wyświetlenia + Żadna ulubiona gra nie pasuje do bieżącego wyszukiwania ani filtrów. Dodaj do ulubionych Usuń z ulubionych Dodaj do ulubionych diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index d62d7a9f9e..949142ac24 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1971,6 +1971,11 @@ Apenas coleções padrão são exibidas. Coleções inteligentes ainda não são compatíveis. Favoritos + Ainda não há favoritos + Toque na estrela de qualquer jogo para adicioná-lo aqui e acessá-lo rapidamente. + Ver todos os jogos + Nenhum favorito para mostrar + Nenhum jogo favorito corresponde à busca ou aos filtros atuais. Adicionar aos favoritos Remover dos favoritos Adicionar aos favoritos diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 9d841a2283..c7efb496b9 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2102,6 +2102,11 @@ Sunt afișate doar colecțiile standard. Colecțiile inteligente nu sunt încă acceptate. Favorite + Încă nu ai favorite + Atinge steaua de pe orice joc pentru a-l adăuga aici pentru acces rapid. + Răsfoiește toate jocurile + Nicio favorită de afișat + Niciun joc favorit nu corespunde căutării sau filtrelor curente. Adaugă la favorite Elimină de la favorite Adaugă la favorite diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cbfb148a41..66630c8ac7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2027,6 +2027,11 @@ https://gamenative.app Показаны только обычные коллекции. Умные коллекции пока не поддерживаются. Избранное + Пока нет избранного + Нажмите на звёздочку у любой игры, чтобы добавить её сюда для быстрого доступа. + Просмотреть все игры + Нет избранного для показа + Нет избранных игр, соответствующих текущему поиску или фильтрам. Добавить в избранное Удалить из избранного Добавить в избранное diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 1f97115863..cc12e943e7 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2095,6 +2095,11 @@ Показано лише звичайні колекції. Розумні колекції ще не підтримуються. Вибране + Ще немає вибраного + Натисніть зірочку на будь-якій грі, щоб додати її сюди для швидкого доступу. + Переглянути всі ігри + Немає вибраного для показу + Немає вибраних ігор, що відповідають поточному пошуку або фільтрам. Додати до вибраного Видалити з вибраного Додати до вибраного diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8626f592f3..48413daf9d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2119,6 +2119,11 @@ 仅显示标准收藏集。智能收藏集尚不受支持。 收藏 + 还没有收藏 + 点击任意游戏上的星标即可将其添加到这里,方便快速访问。 + 浏览所有游戏 + 没有可显示的收藏 + 没有符合当前搜索或筛选条件的收藏游戏。 添加到收藏 从收藏中移除 添加到收藏 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 383963d672..46e52e20a7 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2110,6 +2110,11 @@ 僅顯示標準收藏。智慧型收藏尚不支援。 收藏 + 還沒有收藏 + 點擊任何遊戲上的星號即可將其加入這裡,方便快速存取。 + 瀏覽所有遊戲 + 沒有可顯示的收藏 + 沒有符合目前搜尋或篩選條件的收藏遊戲。 加入收藏 從收藏中移除 加入收藏 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b452630c16..fb44bfcea1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,6 +110,11 @@ Add or play some games to get GOG recommendations based on your library. All Favorites + No favorites yet + Tap the star on any game to add it here for quick access. + Browse all games + No favorites to show + No favorite games match the current search or filters. Steam GOG Epic diff --git a/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt index 31814fb5e9..74700ec859 100644 --- a/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt +++ b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt @@ -75,4 +75,46 @@ class FavoritesUtilsTest { assertEquals(0, count) assertFalse(count == games.size) } + + @Test + fun countPresent_countsOnlyFavoritesInEligibleSet() { + val count = FavoritesUtils.countPresent( + favorites = setOf("1", "2", "3", "orphan"), + eligibleIds = setOf("2", "3", "4"), + ) + + assertEquals(2, count) + } + + @Test + fun countPresent_isZeroWhenNoOverlap() { + val count = FavoritesUtils.countPresent( + favorites = setOf("1", "2"), + eligibleIds = setOf("3", "4"), + ) + + assertEquals(0, count) + } + + @Test + fun countPresent_isZeroWhenFavoritesEmpty() { + val count = FavoritesUtils.countPresent( + favorites = emptySet(), + eligibleIds = setOf("1", "2"), + ) + + assertEquals(0, count) + } + + @Test + fun countPresent_ignoresOrphanedFavoritesNotInEligibleSet() { + // Favorited games that have disappeared from the library (uninstalled source, revoked + // credentials) must not inflate the badge beyond what the tab can actually show. + val count = FavoritesUtils.countPresent( + favorites = setOf("a", "b", "c"), + eligibleIds = emptySet(), + ) + + assertEquals(0, count) + } } From 2cf37561fc62468d71907610706c24fba6031aa6 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Wed, 22 Jul 2026 17:35:36 -0400 Subject: [PATCH 14/18] Polish favourite star: undo, focus ring, scrim, a11y labels Add controller/D-pad focus behavior, a legibility scrim on cover art, and contextual accessibility labels to the favourite star button, plus an Undo snackbar when a favourite is removed. - SnackbarManager gains an optional action + callback (Undo); PluviaMain invokes the callback on SnackbarResult.ActionPerformed. - FavoriteStarButton now owns its interaction source and draws a focusRing, sits on a translucent circular scrim when overlaid on cover art (48dp touch target preserved), and uses named a11y labels (Add/Remove ). - Removal from the grid/list star and the options menu shows an Undo snackbar that re-adds the game. - New strings (favorite_add_named, favorite_remove_named, favorite_removed, favorite_removed_named, undo) localized across all 14 locales. --- .../main/java/app/gamenative/ui/PluviaMain.kt | 19 +++--- .../screen/library/appscreen/BaseAppScreen.kt | 4 +- .../library/components/FavoriteActions.kt | 31 ++++++++++ .../library/components/FavoriteStarButton.kt | 61 +++++++++++++++---- .../library/components/LibraryGridCard.kt | 1 + .../library/components/LibraryListCard.kt | 1 + .../app/gamenative/ui/util/SnackbarManager.kt | 25 ++++++-- 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 | 5 ++ app/src/main/res/values-ko/strings.xml | 5 ++ app/src/main/res/values-pl/strings.xml | 5 ++ app/src/main/res/values-pt-rBR/strings.xml | 5 ++ app/src/main/res/values-ro/strings.xml | 5 ++ app/src/main/res/values-ru/strings.xml | 5 ++ app/src/main/res/values-uk/strings.xml | 5 ++ app/src/main/res/values-zh-rCN/strings.xml | 5 ++ app/src/main/res/values-zh-rTW/strings.xml | 5 ++ app/src/main/res/values/strings.xml | 5 ++ 22 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 1db8e407da..a358181f1a 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -1127,13 +1128,17 @@ fun PluviaMain( var exitSnackbarVisible by remember { mutableStateOf(false) } LaunchedEffect(snackbarController) { - SnackbarManager.messages.collect { message -> - if ( - withTimeoutOrNull(SNACKBAR_SHOW_TIMEOUT_MS) { - snackbarController.hostState.showSnackbar(message) - } == null - ) { - Timber.w("[Snackbar]: Display timed out before dismissal") + SnackbarManager.events.collect { event -> + val result = withTimeoutOrNull(SNACKBAR_SHOW_TIMEOUT_MS) { + snackbarController.hostState.showSnackbar( + message = event.message, + actionLabel = event.actionLabel, + ) + } + when { + result == null -> Timber.w("[Snackbar]: Display timed out before dismissal") + result == SnackbarResult.ActionPerformed -> event.onAction?.invoke() + else -> {} } // snackbar dismissed (timeout or new message) — reset exit flag exitSnackbarVisible = false 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 656200b80a..4fbeff16db 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 @@ -38,6 +38,7 @@ import app.gamenative.ui.component.dialog.NexusModsDialog import app.gamenative.ui.data.AppMenuOption import app.gamenative.ui.data.GameDisplayInfo import app.gamenative.ui.enums.AppOptionMenuType +import app.gamenative.ui.screen.library.components.toggleFavoriteWithUndo import app.gamenative.ui.util.ContainerConfigTransfer import app.gamenative.ui.util.SnackbarManager import app.gamenative.utils.DiagnosticsLog @@ -724,6 +725,7 @@ abstract class BaseAppScreen { @Composable private fun getFavoriteOption(libraryItem: LibraryItem): AppMenuOption { + val context = LocalContext.current val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() val isFavorite = favorites.contains(libraryItem.appId) return AppMenuOption( @@ -733,7 +735,7 @@ abstract class BaseAppScreen { AppOptionMenuType.AddToFavorites }, onClick = { - FavoritesManager.toggle(libraryItem.appId) + toggleFavoriteWithUndo(context, libraryItem.appId, libraryItem.name) }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt new file mode 100644 index 0000000000..3b05139735 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt @@ -0,0 +1,31 @@ +package app.gamenative.ui.screen.library.components + +import android.content.Context +import app.gamenative.R +import app.gamenative.data.FavoritesManager +import app.gamenative.ui.util.SnackbarManager + +/** + * Toggles the favorite state for [appId], and when a game is being *removed* shows a snackbar with + * an "Undo" action that puts it back. Adding a favorite is silent (the filled star is confirmation + * enough); only removals get the safety net, since an accidental un-star is easy to miss. + * + * [gameName] is used to make the message specific ("Removed from favorites"); when it is + * null or blank a generic message is shown instead. + */ +internal fun toggleFavoriteWithUndo(context: Context, appId: String, gameName: String?) { + val wasFavorite = FavoritesManager.isFavorite(appId) + FavoritesManager.toggle(appId) + if (wasFavorite) { + val message = if (gameName.isNullOrBlank()) { + context.getString(R.string.favorite_removed) + } else { + context.getString(R.string.favorite_removed_named, gameName) + } + SnackbarManager.show( + message = message, + actionLabel = context.getString(R.string.undo), + onAction = { FavoritesManager.setFavorite(appId, true) }, + ) + } +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt index cefeb97050..e624913b17 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt @@ -1,6 +1,11 @@ package app.gamenative.ui.screen.library.components +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarOutline @@ -9,13 +14,16 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.gamenative.R import app.gamenative.data.FavoritesManager +import app.gamenative.ui.component.focusRing /** * A star button that shows whether a game is a favorite and toggles it when tapped. @@ -23,15 +31,20 @@ import app.gamenative.data.FavoritesManager * It observes [FavoritesManager] directly, so it can be dropped onto any card or screen without * threading callbacks through the surrounding composables. * - * @param onImage when true, the icon uses a light tint so it stays readable on top of cover art. + * @param gameName used to build a contextual accessibility label ("Add to favorites") and + * the removal snackbar. When null the button falls back to generic labels. + * @param onImage when true, the icon uses a light tint and sits on a subtle circular scrim so it + * stays readable on top of cover art. */ @Composable internal fun FavoriteStarButton( appId: String, modifier: Modifier = Modifier, + gameName: String? = null, iconSize: Int = 20, onImage: Boolean = false, ) { + val context = LocalContext.current val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() val isFavorite = appId in favorites @@ -41,17 +54,43 @@ internal fun FavoriteStarButton( else -> MaterialTheme.colorScheme.onSurfaceVariant } + val contentDescription = when { + gameName.isNullOrBlank() -> stringResource( + if (isFavorite) R.string.favorite_remove else R.string.favorite_add, + ) + isFavorite -> stringResource(R.string.favorite_remove_named, gameName) + else -> stringResource(R.string.favorite_add_named, gameName) + } + + // Own the interaction source so a D-pad / controller focus draws a visible ring on the button + // (the star is often the only focusable overlay on a cover, so it needs its own affordance). + val interactionSource = remember { MutableInteractionSource() } + IconButton( - onClick = { FavoritesManager.toggle(appId) }, - modifier = modifier, + onClick = { toggleFavoriteWithUndo(context, appId, gameName) }, + modifier = modifier.focusRing(interactionSource, CircleShape), + interactionSource = interactionSource, ) { - Icon( - imageVector = if (isFavorite) Icons.Filled.Star else Icons.Filled.StarOutline, - contentDescription = stringResource( - if (isFavorite) R.string.favorite_remove else R.string.favorite_add, - ), - tint = tint, - modifier = Modifier.size(iconSize.dp), - ) + val icon = @Composable { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Filled.StarOutline, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(iconSize.dp), + ) + } + if (onImage) { + // Scrim keeps the star legible over bright or busy cover art without enlarging the + // 48dp touch target the surrounding IconButton already provides. + Box( + modifier = Modifier + .background(Color.Black.copy(alpha = 0.32f), CircleShape) + .padding(4.dp), + ) { + icon() + } + } else { + icon() + } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt index 2c2844a7b1..9b61c4da19 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt @@ -370,6 +370,7 @@ internal fun GridViewCard( FavoriteStarButton( appId = appInfo.appId, + gameName = appInfo.name, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 2.dp, bottom = 2.dp), diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt index 76d529b084..6c8ec5f64a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt @@ -202,6 +202,7 @@ internal fun ListViewCard( if (!appInfo.isRecommended) { FavoriteStarButton( appId = appInfo.appId, + gameName = appInfo.name, iconSize = 22, ) } diff --git a/app/src/main/java/app/gamenative/ui/util/SnackbarManager.kt b/app/src/main/java/app/gamenative/ui/util/SnackbarManager.kt index f021be11d0..b2e091676d 100644 --- a/app/src/main/java/app/gamenative/ui/util/SnackbarManager.kt +++ b/app/src/main/java/app/gamenative/ui/util/SnackbarManager.kt @@ -8,11 +8,26 @@ import kotlinx.coroutines.flow.receiveAsFlow import timber.log.Timber object SnackbarManager { - private val _messages = Channel(capacity = Channel.BUFFERED) - val messages = _messages.receiveAsFlow() - - fun show(message: String) { - if (_messages.trySend(message).isFailure) { + /** + * A snackbar request. [actionLabel] and [onAction] are optional; when both are provided the + * snackbar shows an action button (e.g. "Undo") that invokes [onAction] when tapped. + */ + data class Event( + val message: String, + val actionLabel: String? = null, + val onAction: (() -> Unit)? = null, + ) + + private val _events = Channel(capacity = Channel.BUFFERED) + val events = _events.receiveAsFlow() + + fun show(message: String) = show(Event(message)) + + fun show(message: String, actionLabel: String?, onAction: () -> Unit) = + show(Event(message, actionLabel, onAction)) + + fun show(event: Event) { + if (_events.trySend(event).isFailure) { Timber.w("[Snackbar]: Dropping message because the buffer is full") } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 8123d1e90c..4161f1f955 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1980,4 +1980,9 @@ Fjern fra favoritter Føj til favoritter Fjern fra favoritter + Føj %1$s til favoritter + Fjern %1$s fra favoritter + Fjernet fra favoritter + Fjernede %1$s fra favoritter + Fortryd diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5d10859f7e..abff7d3167 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2050,4 +2050,9 @@ Aus Favoriten entfernen Zu Favoriten hinzufügen Aus Favoriten entfernen + %1$s zu Favoriten hinzufügen + %1$s aus Favoriten entfernen + Aus Favoriten entfernt + %1$s aus Favoriten entfernt + Rückgängig diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8040da2281..2ae01ad0d6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2108,4 +2108,9 @@ Quitar de favoritos Añadir a favoritos Quitar de favoritos + Añadir %1$s a favoritos + Quitar %1$s de favoritos + Quitado de favoritos + %1$s quitado de favoritos + Deshacer diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 20cfe68c4b..d01097b74e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2110,4 +2110,9 @@ Retirer des favoris Ajouter aux favoris Retirer des favoris + Ajouter %1$s aux favoris + Retirer %1$s des favoris + Retiré des favoris + %1$s retiré des favoris + Annuler diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 875348c17b..5dfc7fc546 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2101,4 +2101,9 @@ Rimuovi dai preferiti Aggiungi ai preferiti Rimuovi dai preferiti + Aggiungi %1$s ai preferiti + Rimuovi %1$s dai preferiti + Rimosso dai preferiti + %1$s rimosso dai preferiti + Annulla diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 29ce80c442..1ffbdb47aa 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2067,4 +2067,9 @@ お気に入りから削除 お気に入りに追加 お気に入りから削除 + %1$s をお気に入りに追加 + %1$s をお気に入りから削除 + お気に入りから削除しました + %1$s をお気に入りから削除しました + 元に戻す diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8c7978f408..675919de54 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2108,4 +2108,9 @@ 즐겨찾기에서 제거 즐겨찾기에 추가 즐겨찾기에서 제거 + %1$s을(를) 즐겨찾기에 추가 + %1$s을(를) 즐겨찾기에서 제거 + 즐겨찾기에서 제거됨 + %1$s을(를) 즐겨찾기에서 제거함 + 실행 취소 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 02475008c6..c8721c1e3d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2108,4 +2108,9 @@ Usuń z ulubionych Dodaj do ulubionych Usuń z ulubionych + Dodaj %1$s do ulubionych + Usuń %1$s z ulubionych + Usunięto z ulubionych + Usunięto %1$s z ulubionych + Cofnij diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 949142ac24..f1c895ea83 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1980,4 +1980,9 @@ Remover dos favoritos Adicionar aos favoritos Remover dos favoritos + Adicionar %1$s aos favoritos + Remover %1$s dos favoritos + Removido dos favoritos + %1$s removido dos favoritos + Desfazer diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c7efb496b9..cd46b517ab 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2111,4 +2111,9 @@ Elimină de la favorite Adaugă la favorite Elimină de la favorite + Adaugă %1$s la favorite + Elimină %1$s de la favorite + Eliminat de la favorite + %1$s eliminat de la favorite + Anulează diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 66630c8ac7..ed90057b1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2036,4 +2036,9 @@ https://gamenative.app Удалить из избранного Добавить в избранное Удалить из избранного + Добавить %1$s в избранное + Удалить %1$s из избранного + Удалено из избранного + %1$s удалено из избранного + Отменить diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cc12e943e7..b671970603 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2104,4 +2104,9 @@ Видалити з вибраного Додати до вибраного Видалити з вибраного + Додати %1$s до вибраного + Видалити %1$s з вибраного + Видалено з вибраного + %1$s видалено з вибраного + Скасувати diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 48413daf9d..86ea506322 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2128,4 +2128,9 @@ 从收藏中移除 添加到收藏 从收藏中移除 + 将 %1$s 添加到收藏 + 将 %1$s 从收藏中移除 + 已从收藏中移除 + 已将 %1$s 从收藏中移除 + 撤销 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 46e52e20a7..190f7436f5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2119,4 +2119,9 @@ 從收藏中移除 加入收藏 從收藏中移除 + 將 %1$s 加入收藏 + 將 %1$s 從收藏中移除 + 已從收藏中移除 + 已將 %1$s 從收藏中移除 + 復原 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb44bfcea1..f0a8fa17bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1632,6 +1632,11 @@ Remove from favorites Add to favorites Remove from favorites + Add %1$s to favorites + Remove %1$s from favorites + Removed from favorites + Removed %1$s from favorites + Undo Reset DRM Use known config Import saves From d0229686c55c18934cac3597f4ca96b89ff55470 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Wed, 22 Jul 2026 17:44:33 -0400 Subject: [PATCH 15/18] Add star toggle pop + haptics, halve snackbar duration - FavoriteStarButton plays a spring 'pop' scale when a game is favourited (only on user toggles, not when an already-favourited card scrolls in) and fires haptic feedback on tap. - Halve the snackbar display cap (SNACKBAR_SHOW_TIMEOUT_MS) from 15s to 7.5s so toasts, including the favourite Undo, dismiss faster. --- .../main/java/app/gamenative/ui/PluviaMain.kt | 2 +- .../library/components/FavoriteStarButton.kt | 40 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index a358181f1a..016197116d 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -134,7 +134,7 @@ import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber private const val PENDING_LAUNCH_TIMEOUT_MS = 10_000L -private const val SNACKBAR_SHOW_TIMEOUT_MS = 15_000L +private const val SNACKBAR_SHOW_TIMEOUT_MS = 7_500L /** Used to suspend preLaunchApp while the user decides on large workshop updates. */ private var workshopUpdateDeferred: CompletableDeferred? = null diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt index e624913b17..d85e432459 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt @@ -1,5 +1,8 @@ package app.gamenative.ui.screen.library.components +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -13,11 +16,17 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -45,6 +54,7 @@ internal fun FavoriteStarButton( onImage: Boolean = false, ) { val context = LocalContext.current + val haptics = LocalHapticFeedback.current val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() val isFavorite = appId in favorites @@ -62,12 +72,36 @@ internal fun FavoriteStarButton( else -> stringResource(R.string.favorite_add_named, gameName) } + // Pop the star when it is turned on, but not when a card that is already a favorite first + // appears (e.g. while scrolling) — only user-driven toggles should animate. + val scale = remember { Animatable(1f) } + var isFirstComposition by remember { mutableStateOf(true) } + LaunchedEffect(isFavorite) { + if (isFirstComposition) { + isFirstComposition = false + return@LaunchedEffect + } + if (isFavorite) { + scale.snapTo(0.6f) + scale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + } + } + // Own the interaction source so a D-pad / controller focus draws a visible ring on the button // (the star is often the only focusable overlay on a cover, so it needs its own affordance). val interactionSource = remember { MutableInteractionSource() } IconButton( - onClick = { toggleFavoriteWithUndo(context, appId, gameName) }, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + toggleFavoriteWithUndo(context, appId, gameName) + }, modifier = modifier.focusRing(interactionSource, CircleShape), interactionSource = interactionSource, ) { @@ -76,7 +110,9 @@ internal fun FavoriteStarButton( imageVector = if (isFavorite) Icons.Filled.Star else Icons.Filled.StarOutline, contentDescription = contentDescription, tint = tint, - modifier = Modifier.size(iconSize.dp), + modifier = Modifier + .size(iconSize.dp) + .scale(scale.value), ) } if (onImage) { From 65ddad36edd9ba050b8ebfe20e2fee9122f1b310 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Wed, 22 Jul 2026 17:58:08 -0400 Subject: [PATCH 16/18] Fix Undo action rendering, cold-start pop, haptic weight Address issues found in review of the favourites polish: - The custom SnackbarHost only rendered the message Text, so the Undo action was never shown or reachable (touch, controller, or screen reader). Render the action as a focusable TextButton that calls performAction(). - The pop animation's 'first composition' guard was defeated by the async favorites load: every already-favourited card popped at once when the set finished loading. Gate the animation on FavoritesManager.loaded and only animate a genuine false->true transition observed after load. - Replace the heavy LongPress haptic on the toggle with a light CONTEXT_CLICK tick, which better suits a quick favourite toggle. --- .../main/java/app/gamenative/ui/PluviaMain.kt | 34 +++++++++++++++---- .../library/components/FavoriteStarButton.kt | 30 +++++++++------- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 016197116d..e6e0b9f83a 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -6,6 +6,8 @@ import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -19,6 +21,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -1571,12 +1574,31 @@ fun PluviaMain( color = MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = 4.dp, ) { - Text( - text = data.visuals.message, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - ) + val actionLabel = data.visuals.actionLabel + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding( + start = 24.dp, + end = if (actionLabel != null) 8.dp else 24.dp, + ), + ) { + Text( + text = data.visuals.message, + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, + ) + if (actionLabel != null) { + TextButton(onClick = { data.performAction() }) { + Text( + text = actionLabel, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt index d85e432459..e1eb4cf0e0 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt @@ -1,5 +1,6 @@ package app.gamenative.ui.screen.library.components +import android.view.HapticFeedbackConstants import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring @@ -24,9 +25,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -54,8 +54,9 @@ internal fun FavoriteStarButton( onImage: Boolean = false, ) { val context = LocalContext.current - val haptics = LocalHapticFeedback.current + val view = LocalView.current val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val loaded by FavoritesManager.loaded.collectAsStateWithLifecycle() val isFavorite = appId in favorites val tint = when { @@ -72,16 +73,18 @@ internal fun FavoriteStarButton( else -> stringResource(R.string.favorite_add_named, gameName) } - // Pop the star when it is turned on, but not when a card that is already a favorite first - // appears (e.g. while scrolling) — only user-driven toggles should animate. + // Pop the star when it is turned on by the user, but never when the favorite state simply + // settles for the first time. Favorites load asynchronously (starting from an empty set), so a + // plain "first composition" guard would still let every already-favorited card pop the moment + // the load completes. Instead we only animate a genuine false->true transition observed *after* + // the set has loaded, tracking the last state we acted on. val scale = remember { Animatable(1f) } - var isFirstComposition by remember { mutableStateOf(true) } - LaunchedEffect(isFavorite) { - if (isFirstComposition) { - isFirstComposition = false - return@LaunchedEffect - } - if (isFavorite) { + var lastFavoriteState by remember { mutableStateOf(null) } + LaunchedEffect(isFavorite, loaded) { + if (!loaded) return@LaunchedEffect + val previous = lastFavoriteState + lastFavoriteState = isFavorite + if (previous == false && isFavorite) { scale.snapTo(0.6f) scale.animateTo( targetValue = 1f, @@ -99,7 +102,8 @@ internal fun FavoriteStarButton( IconButton( onClick = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) + // A light context-click tick suits a quick toggle; LongPress would feel too heavy. + view.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) toggleFavoriteWithUndo(context, appId, gameName) }, modifier = modifier.focusRing(interactionSource, CircleShape), From 239125881592a366fec1776dfa2481e85a4cf390 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Wed, 22 Jul 2026 18:12:29 -0400 Subject: [PATCH 17/18] Fix snackbar Undo action layout The message text was unconstrained, so it consumed the row width and squeezed the Undo action into a vertical sliver (or pushed it off-screen for long game names). Make the snackbar fill width with side margins, give the message a weight so it shares space and ellipsizes at two lines, and keep the action label on a single line so Undo always renders at its natural width. --- .../main/java/app/gamenative/ui/PluviaMain.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index e6e0b9f83a..2dea991d8f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -6,7 +6,6 @@ import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +35,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel @@ -1569,15 +1569,17 @@ fun PluviaMain( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomCenter, ) { + val actionLabel = data.visuals.actionLabel Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = 4.dp, ) { - val actionLabel = data.visuals.actionLabel Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding( start = 24.dp, end = if (actionLabel != null) 8.dp else 24.dp, @@ -1585,9 +1587,13 @@ fun PluviaMain( ) { Text( text = data.visuals.message, - modifier = Modifier.padding(vertical = 12.dp), + modifier = Modifier + .weight(1f) + .padding(vertical = 12.dp), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) if (actionLabel != null) { TextButton(onClick = { data.performAction() }) { @@ -1595,6 +1601,8 @@ fun PluviaMain( text = actionLabel, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, + maxLines = 1, + softWrap = false, ) } } From c05213984ac7113de7fe76cc6c756ac58972e4d4 Mon Sep 17 00:00:00 2001 From: Juan Marti Mercado Date: Wed, 22 Jul 2026 18:22:44 -0400 Subject: [PATCH 18/18] Shorten snackbar display time to 6s 7.5s felt too long; reduce SNACKBAR_SHOW_TIMEOUT_MS to 6000ms. --- app/src/main/java/app/gamenative/ui/PluviaMain.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 2dea991d8f..4c466d631f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -137,7 +137,7 @@ import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber private const val PENDING_LAUNCH_TIMEOUT_MS = 10_000L -private const val SNACKBAR_SHOW_TIMEOUT_MS = 7_500L +private const val SNACKBAR_SHOW_TIMEOUT_MS = 6_000L /** Used to suspend preLaunchApp while the user decides on large workshop updates. */ private var workshopUpdateDeferred: CompletableDeferred? = null