diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 5ba1a36910..f3634f6222 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1302,6 +1302,23 @@ object PrefManager { setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value)) } + // 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 FAVORITE_APP_IDS = stringPreferencesKey("favorite_app_ids") + var favoriteAppIds: Set + get() { + val value = getPref(FAVORITE_APP_IDS, "[]") + return try { + Json.decodeFromString>(value) + } catch (e: Exception) { + Timber.w(e, "Failed to decode favorite app ids; falling back to empty set") + emptySet() + } + } + set(value) { + setPref(FAVORITE_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/FavoritesManager.kt b/app/src/main/java/app/gamenative/data/FavoritesManager.kt new file mode 100644 index 0000000000..31f726da80 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavoritesManager.kt @@ -0,0 +1,90 @@ +package app.gamenative.data + +import app.gamenative.PrefManager +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.launch + +/** + * Keeps track of which games the user has marked as favorite. + * + * 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. + * + * 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. 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 favorites. + */ +object FavoritesManager { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _favorites = MutableStateFlow>(emptySet()) + + /** 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() + + /** 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.favoriteAppIds + synchronized(lock) { + var result = stored + for ((appId, favorite) in pendingEdits) { + result = FavoritesUtils.apply(result, appId, favorite) + } + val hadPendingEdits = pendingEdits.isNotEmpty() + pendingEdits.clear() + // 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) { + PrefManager.favoriteAppIds = result + } + } + } + } + + fun isFavorite(appId: String): Boolean = _favorites.value.contains(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 setFavorite(appId: String, favorite: Boolean) { + synchronized(lock) { + val updated = FavoritesUtils.apply(_favorites.value, appId, favorite) + if (updated == _favorites.value) return + _favorites.value = updated + if (_loaded.value) { + PrefManager.favoriteAppIds = updated + } else { + // Still loading: record the intent so the load replays it on top of the saved set. + 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..3d2981d681 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavoritesUtils.kt @@ -0,0 +1,26 @@ +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 } + + /** 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/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 1db8e407da..4c466d631f 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -6,6 +6,7 @@ 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.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -16,8 +17,10 @@ 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.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -32,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 @@ -133,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 = 15_000L +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 @@ -1127,13 +1131,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 @@ -1561,17 +1569,44 @@ 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, ) { - Text( - text = data.visuals.message, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + start = 24.dp, + end = if (actionLabel != null) 8.dp else 24.dp, + ), + ) { + Text( + text = data.visuals.message, + 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() }) { + Text( + text = actionLabel, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + softWrap = false, + ) + } + } + } } } } 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..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,6 +71,7 @@ data class LibraryState( val epicCount: Int = 0, val amazonCount: Int = 0, val localCount: 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 1f846481e9..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,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), + 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 f617b77abf..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,6 +37,15 @@ enum class LibraryTab( showAmazon = true, installedOnly = false, ), + FAVORITES( + labelResId = R.string.tab_favorites, + 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..a1a92e038c 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,8 @@ import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.R import app.gamenative.data.GameCompatibilityStatus +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 @@ -70,6 +72,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 @@ -113,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() @@ -177,6 +185,23 @@ class LibraryViewModel @Inject constructor( } } + // 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 { favorites -> + if (_state.value.currentTab == LibraryTab.FAVORITES) { + onFilterApps(paginationCurrentPage) + } else { + val count = FavoritesUtils.countPresent(favorites, favoriteEligibleAppIds) + _state.update { it.copy(favoritesCount = count) } + } + } + } + @OptIn(ExperimentalCoroutinesApi::class) viewModelScope.launch(Dispatchers.IO) { // Re-create the underlying DAO Flow whenever the EXPIRED filter is toggled, @@ -946,12 +971,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 favoriteIds = FavoritesManager.favorites.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.FAVORITES) { + FavoritesUtils.filter(entries, favoriteIds) { it.item.appId } + } else { + entries + } }.sortedWith(sortComparator).mapIndexed { idx, entry -> entry.item.copy(index = idx, isInstalled = entry.isInstalled) } @@ -961,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 @@ -1019,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 @@ -1039,6 +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, + 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 fa908eae7f..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) { @@ -1048,6 +1075,7 @@ private fun LibraryScreenContent( currentTab = state.currentTab, tabCounts = mapOf( LibraryTab.ALL to state.allCount, + 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 03d6f40f59..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 @@ -22,11 +22,13 @@ 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 import app.gamenative.R import app.gamenative.data.GameSource +import app.gamenative.data.FavoritesManager import app.gamenative.data.LibraryItem import app.gamenative.events.AndroidEvent import app.gamenative.mods.ModContainerResolver @@ -36,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 @@ -720,6 +723,23 @@ abstract class BaseAppScreen { return emptyList() } + @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( + optionType = if (isFavorite) { + AppOptionMenuType.RemoveFromFavorites + } else { + AppOptionMenuType.AddToFavorites + }, + onClick = { + toggleFavoriteWithUndo(context, libraryItem.appId, libraryItem.name) + }, + ) + } + @Composable private fun getSubmitFeedbackOption(context: Context, libraryItem: LibraryItem): AppMenuOption { return AppMenuOption( @@ -960,6 +980,7 @@ abstract class BaseAppScreen { } // Always available options + 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/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 new file mode 100644 index 0000000000..e1eb4cf0e0 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt @@ -0,0 +1,136 @@ +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 +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 +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.platform.LocalContext +import androidx.compose.ui.platform.LocalView +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. + * + * It observes [FavoritesManager] directly, so it can be dropped onto any card or screen without + * threading callbacks through the surrounding composables. + * + * @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 view = LocalView.current + val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val loaded by FavoritesManager.loaded.collectAsStateWithLifecycle() + val isFavorite = appId in favorites + + val tint = when { + isFavorite -> MaterialTheme.colorScheme.primary + onImage -> Color.White.copy(alpha = 0.85f) + 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) + } + + // 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 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, + 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 = { + // 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), + interactionSource = interactionSource, + ) { + val icon = @Composable { + Icon( + imageVector = if (isFavorite) Icons.Filled.Star else Icons.Filled.StarOutline, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier + .size(iconSize.dp) + .scale(scale.value), + ) + } + 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/GameOptionsPanel.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt index 461238a961..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 @@ -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.AddToFavorites -> Icons.Filled.StarOutline + AppOptionMenuType.RemoveFromFavorites -> 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/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/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt index 6ab394aca6..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 @@ -367,6 +367,16 @@ internal fun GridViewCard( .padding(top = topIconPadding, end = topIconPadding), iconSize = if (isCapsule) 14 else 12, ) + + FavoriteStarButton( + appId = appInfo.appId, + gameName = appInfo.name, + 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..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 @@ -198,6 +198,14 @@ internal fun ListViewCard( showLabel = true, ) } + + 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 48a704eaa0..4161f1f955 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1969,4 +1969,20 @@ Viser senest synkroniserede samlinger. Opret forbindelse for at opdatere. Ryd 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 + 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 6315d3fa03..abff7d3167 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2039,4 +2039,20 @@ 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 + 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 + 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 d8abd02c18..2ae01ad0d6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2097,4 +2097,20 @@ 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ú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 + 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 e939d53fb2..d01097b74e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2099,4 +2099,20 @@ 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 + 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 + 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 761f8a3fff..5dfc7fc546 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2090,4 +2090,20 @@ Visualizzazione delle ultime raccolte sincronizzate. Connettiti per aggiornare. Cancella 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 + 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 5d0ac0b3ac..1ffbdb47aa 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2056,4 +2056,20 @@ 最後に同期されたコレクションを表示しています。更新するには接続してください。 クリア 標準コレクションのみ表示されます。スマートコレクションはまだ対応していません。 + + お気に入り + お気に入りはまだありません + ゲームの星アイコンをタップすると、ここに追加してすぐにアクセスできます。 + すべてのゲームを見る + 表示するお気に入りがありません + 現在の検索やフィルターに一致するお気に入りのゲームはありません。 + お気に入りに追加 + お気に入りから削除 + お気に入りに追加 + お気に入りから削除 + %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 ac9606d772..675919de54 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2097,4 +2097,20 @@ 마지막으로 동기화된 컬렉션을 표시하고 있습니다. 업데이트하려면 연결하세요. 지우기 표준 컬렉션만 표시됩니다. 스마트 컬렉션은 아직 지원되지 않습니다. + + 즐겨찾기 + 아직 즐겨찾기가 없습니다 + 게임의 별 아이콘을 눌러 여기에 추가하고 빠르게 이용하세요. + 모든 게임 보기 + 표시할 즐겨찾기가 없습니다 + 현재 검색어나 필터와 일치하는 즐겨찾기 게임이 없습니다. + 즐겨찾기에 추가 + 즐겨찾기에서 제거 + 즐겨찾기에 추가 + 즐겨찾기에서 제거 + %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 4535d6cd88..c8721c1e3d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2097,4 +2097,20 @@ 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 + 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 + 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 b7087eeda5..f1c895ea83 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1969,4 +1969,20 @@ 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 + 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 + 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 4b1d7ef0d4..cd46b517ab 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2100,4 +2100,20 @@ 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 + Î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 + 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 89272bf8c5..ed90057b1d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2025,4 +2025,20 @@ 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 bc977015dd..b671970603 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2093,4 +2093,20 @@ Показано останні синхронізовані колекції. Підключіться, щоб оновити. Очистити Показано лише звичайні колекції. Розумні колекції ще не підтримуються. + + Вибране + Ще немає вибраного + Натисніть зірочку на будь-якій грі, щоб додати її сюди для швидкого доступу. + Переглянути всі ігри + Немає вибраного для показу + Немає вибраних ігор, що відповідають поточному пошуку або фільтрам. + Додати до вибраного + Видалити з вибраного + Додати до вибраного + Видалити з вибраного + Додати %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 ef3891881b..86ea506322 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2117,4 +2117,20 @@ 正在显示上次同步的收藏集。请连接以更新。 清除 仅显示标准收藏集。智能收藏集尚不受支持。 + + 收藏 + 还没有收藏 + 点击任意游戏上的星标即可将其添加到这里,方便快速访问。 + 浏览所有游戏 + 没有可显示的收藏 + 没有符合当前搜索或筛选条件的收藏游戏。 + 添加到收藏 + 从收藏中移除 + 添加到收藏 + 从收藏中移除 + 将 %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 05650862d7..190f7436f5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2108,4 +2108,20 @@ 正在顯示上次同步的收藏。請連線以更新。 清除 僅顯示標準收藏。智慧型收藏尚不支援。 + + 收藏 + 還沒有收藏 + 點擊任何遊戲上的星號即可將其加入這裡,方便快速存取。 + 瀏覽所有遊戲 + 沒有可顯示的收藏 + 沒有符合目前搜尋或篩選條件的收藏遊戲。 + 加入收藏 + 從收藏中移除 + 加入收藏 + 從收藏中移除 + 將 %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 dfd5518d88..f0a8fa17bf 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,6 +109,12 @@ Recommended 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 @@ -1622,6 +1628,15 @@ Reset container Get support Submit feedback + Add to favorites + 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 diff --git a/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt new file mode 100644 index 0000000000..74700ec859 --- /dev/null +++ b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt @@ -0,0 +1,120 @@ +package app.gamenative.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FavoritesUtilsTest { + + private data class Game(val appId: String, val name: String) + + @Test + fun apply_addsAppIdWhenFavoriteIsTrue() { + val result = FavoritesUtils.apply(setOf("a"), "b", favorite = true) + + assertEquals(setOf("a", "b"), result) + } + + @Test + fun apply_removesAppIdWhenFavoriteIsFalse() { + val result = FavoritesUtils.apply(setOf("a", "b"), "b", favorite = false) + + assertEquals(setOf("a"), result) + } + + @Test + fun apply_isIdempotentWhenAlreadyInDesiredState() { + val current = setOf("a") + + assertEquals(current, FavoritesUtils.apply(current, "a", favorite = true)) + assertEquals(current, FavoritesUtils.apply(current, "b", favorite = false)) + } + + @Test + fun filter_keepsOnlyFavoritesAndPreservesOrder() { + val games = listOf( + Game(appId = "1", name = "First"), + Game(appId = "2", name = "Second"), + Game(appId = "3", name = "Third"), + ) + + val result = FavoritesUtils.filter(games, favorites = setOf("3", "1")) { it.appId } + + assertEquals(listOf("First", "Third"), result.map { it.name }) + } + + @Test + fun filter_returnsEmptyWhenNothingIsFavorited() { + val games = listOf(Game(appId = "1", name = "First")) + + val result = FavoritesUtils.filter(games, favorites = emptySet()) { it.appId } + + assertTrue(result.isEmpty()) + } + + @Test + fun count_matchesTheNumberOfFavoritedItems() { + val games = listOf( + Game(appId = "1", name = "First"), + Game(appId = "2", name = "Second"), + Game(appId = "3", name = "Third"), + ) + + val count = FavoritesUtils.count(games, favorites = setOf("1", "3", "missing")) { it.appId } + + assertEquals(2, count) + } + + @Test + fun count_ignoresFavoriteIdsThatAreNotInTheList() { + val games = listOf(Game(appId = "1", name = "First")) + + val count = FavoritesUtils.count(games, favorites = setOf("99")) { it.appId } + + 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) + } +}