diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 008a39768a..10d505b9a8 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -50,6 +50,8 @@ object PrefManager { ) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val favoritePersistenceLock = Any() + private var favoritePersistenceVersion = 0L private lateinit var dataStore: DataStore @@ -1349,6 +1351,37 @@ object PrefManager { setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value)) } + 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) { + // Keep JSON encoding off the caller thread. The version check prevents an older + // serialization from overwriting a newer favorite set if several toggles are queued. + val version = synchronized(favoritePersistenceLock) { + favoritePersistenceVersion += 1 + favoritePersistenceVersion + } + scope.launch { + val serialized = Json.encodeToString(value) + dataStore.edit { pref -> + val isLatest = synchronized(favoritePersistenceLock) { + version == favoritePersistenceVersion + } + if (isLatest) { + pref[FAVORITE_APP_IDS] = serialized + } + } + } + } + // 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..7bd6a5c884 --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavoritesManager.kt @@ -0,0 +1,82 @@ +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 +import timber.log.Timber + +/** + * 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 and [toggle] returns null (the tap is ignored), so an early + * toggle can never overwrite previously saved favorites with a partial set. + */ +object FavoritesManager { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _favorites = MutableStateFlow>(emptySet()) + + 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() + + init { + scope.launch { + try { + val stored = try { + PrefManager.favoriteAppIds + } catch (e: Exception) { + Timber.tag("FavoritesManager").e(e, "Failed to load favorite app ids") + emptySet() + } + synchronized(lock) { + // 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 = stored + } + } catch (e: Exception) { + Timber.tag("FavoritesManager").e(e, "Failed to initialize favorite app ids") + synchronized(lock) { + _favorites.value = emptySet() + } + } finally { + _loaded.value = true + } + } + } + + /** Returns the new favorite state, or null if the toggle was ignored (set not loaded yet). */ + internal fun toggle(appId: String): Boolean? { + synchronized(lock) { + if (!_loaded.value) return null + val favorite = appId !in _favorites.value + val updated = FavoritesUtils.apply(_favorites.value, appId, favorite) + if (updated == _favorites.value) return null + _favorites.value = updated + PrefManager.favoriteAppIds = updated + return 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..51bd62d05e --- /dev/null +++ b/app/src/main/java/app/gamenative/data/FavoritesUtils.kt @@ -0,0 +1,13 @@ +package app.gamenative.data + +internal object FavoritesUtils { + + fun apply(current: Set, appId: String, favorite: Boolean): Set = + if (favorite) current + appId else current - appId + + fun filter(items: List, favorites: Set, id: (T) -> String): List = + items.filter { id(it) in favorites } + + fun countPresent(favorites: Set, eligibleIds: Set): Int = + favorites.count { it in eligibleIds } +} 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 81d890a8d9..c0235814b7 100644 --- a/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt +++ b/app/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.kt @@ -36,4 +36,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 ab58c2789e..5027b5c67d 100644 --- a/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt +++ b/app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt @@ -3,6 +3,7 @@ package app.gamenative.ui.enums import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Explore +import androidx.compose.material.icons.rounded.Star import androidx.compose.ui.graphics.vector.ImageVector import app.gamenative.PrefManager import app.gamenative.R @@ -36,6 +37,16 @@ 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, + icon = Icons.Rounded.Star, + ), 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 e53260fa85..0774bed3a4 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.BuildConfig import app.gamenative.PluviaApp import app.gamenative.PrefManager import app.gamenative.R +import app.gamenative.data.FavoritesManager +import app.gamenative.data.FavoritesUtils import app.gamenative.data.GameCompatibilityStatus import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem @@ -71,12 +73,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest 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 import kotlinx.coroutines.launch import timber.log.Timber +import java.util.concurrent.atomic.AtomicLong private const val PLAYABLE_FPS_THRESHOLD = 30 private const val PROVEN_RUNS_THRESHOLD = 5 @@ -121,6 +126,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, 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() @@ -142,6 +152,8 @@ class LibraryViewModel @Inject constructor( // Track debounce job for search private var searchDebounceJob: Job? = null private val SEARCH_DEBOUNCE_MS = 500L // 500ms debounce + private var filterJob: Job? = null + private val filterGeneration = AtomicLong(0L) // Cache GPU name to avoid repeated calls private val gpuName: String by lazy { @@ -187,6 +199,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) + .collectLatest { favorites -> + if (_state.value.currentTab == LibraryTab.FAVORITES) { + onFilterApps(paginationCurrentPage).join() + } 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, @@ -614,8 +643,11 @@ class LibraryViewModel @Inject constructor( } private fun onFilterApps(paginationPage: Int = 0): Job { + val generation = filterGeneration.incrementAndGet() Timber.tag("LibraryViewModel").d("onFilterApps - appList.size: ${appList.size}, isFirstLoad: $isFirstLoad") - return viewModelScope.launch(Dispatchers.IO) { + filterJob?.cancel() + val job = viewModelScope.launch(Dispatchers.IO) { + if (generation != filterGeneration.get()) return@launch _state.update { it.copy(isLoading = true) } val currentState = _state.value @@ -996,26 +1028,42 @@ 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) } + // A newer refresh may have taken a snapshot while this pass was doing the expensive + // filtering. Never let this pass publish its obsolete list or pagination metadata. + if (generation != filterGeneration.get()) return@launch + // Total count for the current filter val totalFound = combined.size // 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 @@ -1068,13 +1116,28 @@ class LibraryViewModel @Inject constructor( isFirstLoad = false } + if (generation != filterGeneration.get()) return@launch + // Fetch compatibility for current page games fetchCompatibilityForPage(pagedList.map { it.name }) + // App ids across every source the Favorites tab shows. 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 } + if (generation != filterGeneration.get()) return@launch + 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 @@ -1091,9 +1154,12 @@ 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), ) } } + filterJob = job + return job } /** 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 e09da81157..f2eecee8e0 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 @@ -79,6 +79,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 @@ -99,11 +100,13 @@ 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 import app.gamenative.ui.screen.library.components.LibrarySourceNotLoggedInSplash import app.gamenative.ui.screen.library.components.LibraryTabBar +import app.gamenative.ui.screen.library.components.toggleFavorite import app.gamenative.ui.screen.auth.AmazonOAuthActivity import app.gamenative.ui.screen.auth.EpicOAuthActivity import app.gamenative.ui.screen.auth.GOGOAuthActivity @@ -362,6 +365,8 @@ private fun LibraryScreenContent( var wasOptionsPanelOpen by remember { mutableStateOf(false) } // Keep a stable reference to the selected item so detail view doesn't disappear during list refresh/pagination. var selectedLibraryItem by remember { mutableStateOf(null) } + val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val favoritesLoaded by FavoritesManager.loaded.collectAsStateWithLifecycle() val filterFabExpanded by remember(currentPaneType, listState, carouselListState) { derivedStateOf { if (currentPaneType == PaneType.CAROUSEL) { @@ -467,6 +472,22 @@ private fun LibraryScreenContent( } catch (_: IllegalStateException) {} } + fun focusedLibraryItem(): LibraryItem? { + if (state.currentTab == LibraryTab.RECOMMENDED) return null + val focusedIndex = if (currentPaneType == PaneType.CAROUSEL) { + currentCarouselFocusTargetIndex() + } else { + gridFocusTargetListIndex + } + return state.appInfoList.getOrNull(focusedIndex)?.takeUnless { it.isRecommended } + } + + fun toggleFocusedFavorite(): Boolean { + val item = focusedLibraryItem() ?: return false + toggleFavorite(context, item.appId, item.name) + return true + } + val storagePermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions(), ) { } @@ -908,11 +929,15 @@ private fun LibraryScreenContent( } } - // X button - add custom game + // X button - toggle favorite for the focused game KeyEvent.KEYCODE_BUTTON_X -> { - if (selectedAppId == null && !state.isSearching && !state.isOptionsPanelOpen && !isSystemMenuOpen) { - onAddCustomGameClick() - true + if (selectedAppId == null && + !state.isSearching && + !state.isOptionsPanelOpen && + !isSystemMenuOpen && + !tabBarHasFocus + ) { + toggleFocusedFavorite() } else { false } @@ -1001,6 +1026,11 @@ 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 showFavoritesEmptyState = state.currentTab == LibraryTab.FAVORITES && + favoritesLoaded && !state.isLoading && state.appInfoList.isEmpty() if (showEmptyStateSplash) { val (messageResId, buttonResId, onAction) = when (state.currentTab) { LibraryTab.STEAM -> Triple( @@ -1036,6 +1066,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) { @@ -1081,6 +1129,7 @@ private fun LibraryScreenContent( }, onRefresh = onRefresh, modifier = Modifier.fillMaxSize(), + onFocusedIndexChanged = { gridFocusTargetListIndex = it }, ) } } @@ -1114,6 +1163,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, @@ -1209,12 +1259,18 @@ private fun LibraryScreenContent( labelResId = R.string.search, onClick = { onIsSearching(true) }, ), - ) + listOf( - GamepadAction( - button = GamepadButton.X, - labelResId = R.string.action_add_game, - onClick = onAddCustomGameClick, - ), + ) + listOfNotNull( + focusedLibraryItem()?.let { item -> + GamepadAction( + button = GamepadButton.X, + labelResId = if (item.appId in favorites) { + R.string.option_remove_from_favorites + } else { + R.string.option_add_to_favorites + }, + onClick = { toggleFocusedFavorite() }, + ) + }, ) } 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 ce1a667ed9..12f94e1fda 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 @@ -29,6 +30,7 @@ import app.gamenative.R import app.gamenative.api.isValidCommunityConfig import app.gamenative.api.prepareCommunityConfigForApply 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 @@ -40,6 +42,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.toggleFavorite import app.gamenative.ui.util.ContainerConfigTransfer import app.gamenative.ui.util.SnackbarManager import app.gamenative.utils.BestConfigService @@ -759,6 +762,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 = { + toggleFavorite(context, libraryItem.appId, libraryItem.name) + }, + ) + } + @Composable private fun getSubmitFeedbackOption(context: Context, libraryItem: LibraryItem): AppMenuOption { return AppMenuOption( @@ -1115,6 +1135,9 @@ abstract class BaseAppScreen { } // Always available options + if (!libraryItem.isRecommended) { + 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..f750fb720b --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt @@ -0,0 +1,23 @@ +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 shows a confirmation snackbar. + * + * [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 toggleFavorite(context: Context, appId: String, gameName: String?) { + val favorite = FavoritesManager.toggle(appId) ?: return + val message = when { + favorite && gameName.isNullOrBlank() -> context.getString(R.string.favorite_added) + favorite -> context.getString(R.string.favorite_added_named, gameName) + gameName.isNullOrBlank() -> context.getString(R.string.favorite_removed) + else -> context.getString(R.string.favorite_removed_named, gameName) + } + SnackbarManager.show(message) +} diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteCardIndicator.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteCardIndicator.kt new file mode 100644 index 0000000000..a774cd44c3 --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteCardIndicator.kt @@ -0,0 +1,104 @@ +package app.gamenative.ui.screen.library.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +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.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.gamenative.data.FavoritesManager +import app.gamenative.ui.theme.PluviaWarning + +internal class FavoriteCardIndicator( + val isFavorite: Boolean, + val glowAlpha: Float, +) + +@Composable +internal fun rememberFavoriteCardIndicator( + appId: String, + isRecommended: Boolean, +): FavoriteCardIndicator { + val favorites by FavoritesManager.favorites.collectAsStateWithLifecycle() + val favoritesLoaded by FavoritesManager.loaded.collectAsStateWithLifecycle() + val isFavorite = !isRecommended && appId in favorites + val glowAlpha = remember { Animatable(1f) } + var wasReady by remember { mutableStateOf(false) } + var previousIsFavorite by remember { mutableStateOf(isFavorite) } + + LaunchedEffect(favoritesLoaded, isFavorite) { + if (!favoritesLoaded) return@LaunchedEffect + + if (wasReady && !previousIsFavorite && isFavorite) { + glowAlpha.snapTo(0.45f) + glowAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 240), + ) + } + + previousIsFavorite = isFavorite + wasReady = true + } + + return FavoriteCardIndicator( + isFavorite = isFavorite, + glowAlpha = glowAlpha.value, + ) +} + +internal fun Modifier.favoriteInnerGlow( + isFavorite: Boolean, + glowAlpha: Float, + shape: Shape, +): Modifier { + if (!isFavorite) return this + + return drawWithCache { + val strokePx = 4.dp.toPx() + val outline = shape.createOutline(size, layoutDirection, this) + val bounds = Rect(Offset.Zero, size) + val gradient = Brush.verticalGradient( + colorStops = arrayOf( + 0f to PluviaWarning.copy(alpha = 0.36f * glowAlpha), + 0.5f to PluviaWarning.copy(alpha = 0.28f * glowAlpha), + 0.82f to PluviaWarning.copy(alpha = 0.14f * glowAlpha), + 1f to PluviaWarning.copy(alpha = 0.04f * glowAlpha), + ), + ) + val layerPaint = Paint() + val clipPath = Path().apply { + when (outline) { + is Outline.Rectangle -> addRect(outline.rect) + is Outline.Rounded -> addRoundRect(outline.roundRect) + is Outline.Generic -> addPath(outline.path) + } + } + + onDrawWithContent { + drawContent() + val canvas = drawContext.canvas + canvas.saveLayer(bounds, layerPaint) + canvas.clipPath(clipPath) + drawOutline(outline, color = androidx.compose.ui.graphics.Color.Black, style = Stroke(strokePx * 2f)) + drawRect(brush = gradient, blendMode = BlendMode.SrcIn) + canvas.restore() + } + } +} 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 233650aaeb..5b70662ab7 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 @@ -55,6 +55,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 @@ -353,6 +355,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 } } @@ -370,6 +374,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..6125d660bb --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryFavoritesEmptyState.kt @@ -0,0 +1,72 @@ +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 + +@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 e2d0571d36..41e179cca2 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 @@ -51,6 +51,10 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextAlign @@ -141,6 +145,39 @@ internal fun GridViewCard( if (isItemFocused) onFocus() } + val favoriteIndicator = rememberFavoriteCardIndicator( + appId = appInfo.appId, + isRecommended = appInfo.isRecommended, + ) + val favoriteActionLabel = if (!appInfo.isRecommended) { + stringResource( + if (favoriteIndicator.isFavorite) { + R.string.favorite_remove_named + } else { + R.string.favorite_add_named + }, + appInfo.name, + ) + } else { + null + } + val favoriteState = if (favoriteIndicator.isFavorite) stringResource(R.string.favorite_added) else null + val favoriteSemantics = if (favoriteActionLabel != null) { + Modifier.semantics(mergeDescendants = true) { + if (favoriteState != null) { + stateDescription = favoriteState + } + customActions = listOf( + CustomAccessibilityAction(favoriteActionLabel) { + toggleFavorite(context, appInfo.appId, appInfo.name) + true + }, + ) + } + } else { + Modifier + } + Box( modifier = modifier .padding(vertical = 4.dp) @@ -152,6 +189,12 @@ internal fun GridViewCard( .fillMaxWidth() .aspectRatio(aspectRatio) .focusRing(interactionSource, cardShape) + .favoriteInnerGlow( + isFavorite = favoriteIndicator.isFavorite, + glowAlpha = favoriteIndicator.glowAlpha, + shape = cardShape, + ) + .then(favoriteSemantics) .clickable( onClick = onClick, interactionSource = interactionSource, 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 1dd7872979..c888e331b3 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 @@ -37,6 +37,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -78,6 +82,39 @@ internal fun ListViewCard( if (isItemFocused) onFocus() } + val favoriteIndicator = rememberFavoriteCardIndicator( + appId = appInfo.appId, + isRecommended = appInfo.isRecommended, + ) + val favoriteActionLabel = if (!appInfo.isRecommended) { + stringResource( + if (favoriteIndicator.isFavorite) { + R.string.favorite_remove_named + } else { + R.string.favorite_add_named + }, + appInfo.name, + ) + } else { + null + } + val favoriteState = if (favoriteIndicator.isFavorite) stringResource(R.string.favorite_added) else null + val favoriteSemantics = if (favoriteActionLabel != null) { + Modifier.semantics(mergeDescendants = true) { + if (favoriteState != null) { + stateDescription = favoriteState + } + customActions = listOf( + CustomAccessibilityAction(favoriteActionLabel) { + toggleFavorite(context, appInfo.appId, appInfo.name) + true + }, + ) + } + } else { + Modifier + } + val shape = RoundedCornerShape(14.dp) Box( modifier = modifier @@ -88,6 +125,12 @@ internal fun ListViewCard( Card( modifier = Modifier .fillMaxWidth() + .favoriteInnerGlow( + isFavorite = favoriteIndicator.isFavorite, + glowAlpha = favoriteIndicator.glowAlpha, + shape = shape, + ) + .then(favoriteSemantics) .clickable( onClick = onClick, interactionSource = interactionSource, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt index 8572e9185a..ef4445725b 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListPane.kt @@ -122,6 +122,7 @@ internal fun LibraryListPane( onNavigate: (String) -> Unit, onRefresh: () -> Unit, modifier: Modifier = Modifier, + onFocusedIndexChanged: (Int) -> Unit = {}, ) { val context = LocalContext.current val snackBarHost = remember { SnackbarHostState() } @@ -296,7 +297,10 @@ internal fun LibraryListPane( appInfo = item, onClick = { onNavigate(item.appId) }, paneType = currentLayout, - onFocus = { targetOfScroll = item.index }, + onFocus = { + targetOfScroll = item.index + onFocusedIndexChanged(listIndex) + }, imageRefreshCounter = state.imageRefreshCounter, compatibilityStatus = state.compatibilityMap[item.name], gameStats = state.statsFor(item), diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt index 23a91d0aa2..3bac7a2418 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt @@ -239,12 +239,24 @@ private fun CompactLibraryTabBar( else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } if (tab.icon != null) { - Icon( - imageVector = tab.icon, - contentDescription = stringResource(tab.labelResId), - tint = tabColor, - modifier = Modifier.size(18.dp), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = tab.icon, + contentDescription = stringResource(tab.labelResId), + tint = tabColor, + modifier = Modifier.size(18.dp), + ) + if (count != null && count > 0) { + Text( + text = "($count)", + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + color = tabColor, + modifier = Modifier.padding(start = 4.dp), + ) + } + } } else { val label = if (count != null && count > 0) { stringResource(R.string.library_tab_with_count, stringResource(tab.labelResId), count) @@ -632,15 +644,30 @@ private fun TabItem( contentAlignment = Alignment.Center, ) { if (tab.icon != null) { - Icon( - imageVector = tab.icon, - contentDescription = stringResource(tab.labelResId), - tint = when { - isSelected -> MaterialTheme.colorScheme.onPrimary - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha) - }, - modifier = Modifier.size(20.dp), - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = tab.icon, + contentDescription = stringResource(tab.labelResId), + tint = when { + isSelected -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha) + }, + modifier = Modifier.size(20.dp), + ) + if (count != null && count > 0) { + Text( + text = "($count)", + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + color = when { + isSelected -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = textAlpha) + }, + modifier = Modifier.padding(start = 4.dp), + ) + } + } } else { Text( text = label, diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index cf86bc75f6..c3d39542b4 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2187,4 +2187,18 @@ Dobbeltklik på et joystick for at skifte mellem Xbox-controller og XR-pointer XR-pointertilstand Xbox-controllertilstand + Favoritter + Ingen favoritter endnu + Åbn spillets detaljemenu for at føje det til favoritter, eller tryk på controllerens venstre frontknap, mens et spil er i fokus (X på Xbox, firkant på PlayStation eller Y på Nintendo-layout). + Gennemse alle spil + Ingen favoritter at vise + Ingen favoritspil matcher den aktuelle søgning eller filtre eller er tilgængelige i dit bibliotek. + 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 + Føjet til favoritter + Føjede %1$s til favoritter diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4a3fb91075..7a89d93a73 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2257,4 +2257,18 @@ Doppelklicke einen Joystick, um zwischen Xbox-Controller und XR-Zeiger zu wechseln XR-Zeigermodus Xbox-Controller-Modus + Favoriten + Noch keine Favoriten + Öffne das Detailmenü eines Spiels, um es zu den Favoriten hinzuzufügen, oder drücke die linke Aktionstaste des Controllers, während ein Spiel fokussiert ist (X bei Xbox, Quadrat bei PlayStation oder Y bei Nintendo-Tastenlayout). + Alle Spiele durchsuchen + Keine Favoriten anzuzeigen + Keine Favoriten-Spiele entsprechen der aktuellen Suche oder den Filtern oder sind in deiner Bibliothek verfügbar. + 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 + Zu Favoriten hinzugefügt + %1$s zu Favoriten hinzugefügt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d7d2a54ed0..42aeff49e4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2315,4 +2315,18 @@ Haz doble clic en un joystick para cambiar entre mando Xbox y puntero XR Modo puntero XR Modo mando Xbox + Favoritos + Aún no hay favoritos + Abre el menú de detalles del juego para añadirlo a favoritos, o pulsa el botón frontal izquierdo del mando mientras el juego está seleccionado (X en Xbox, Cuadrado en PlayStation o Y en mandos con distribución Nintendo). + Ver todos los juegos + No hay favoritos para mostrar + Ningún juego favorito coincide con la búsqueda o los filtros actuales o está disponible en tu biblioteca. + 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 + Añadido a favoritos + %1$s añadido a favoritos diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 79faca7b3c..e4a6bb2f46 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2317,4 +2317,18 @@ Maximise les performances sur les goulots d\'étranglement détectés Conservateur Ajustements progressifs avec accent sur la stabilité + Favoris + Aucun favori pour l’instant + Ouvrez le menu des détails du jeu pour l’ajouter aux favoris, ou appuyez sur le bouton de façade gauche de la manette lorsque le jeu est sélectionné (X sur Xbox, Carré sur PlayStation ou Y avec une disposition Nintendo). + Parcourir tous les jeux + Aucun favori à afficher + Aucun jeu favori ne correspond à la recherche ou aux filtres actuels ou n’est disponible dans votre bibliothèque. + Ajouter aux favoris + Retirer des favoris + Ajouter %1$s aux favoris + Retirer %1$s des favoris + Retiré des favoris + %1$s retiré des favoris + Ajouté aux favoris + %1$s ajouté aux favoris diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ba911a8917..a02cd1f556 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2308,4 +2308,18 @@ Fai doppio clic su una levetta per passare da controller Xbox a puntatore XR Modalità puntatore XR Modalità controller Xbox + Preferiti + Ancora nessun preferito + Apri il menu dei dettagli del gioco per aggiungerlo ai preferiti, oppure premi il pulsante frontale sinistro del controller mentre il gioco è selezionato (X su Xbox, Quadrato su PlayStation o Y con layout Nintendo). + Sfoglia tutti i giochi + Nessun preferito da mostrare + Nessun gioco preferito corrisponde alla ricerca o ai filtri attuali o è disponibile nella tua libreria. + Aggiungi ai preferiti + Rimuovi dai preferiti + Aggiungi %1$s ai preferiti + Rimuovi %1$s dai preferiti + Rimosso dai preferiti + %1$s rimosso dai preferiti + Aggiunto ai preferiti + %1$s aggiunto ai preferiti diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 17e546690c..c3eb2e7b98 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2271,4 +2271,18 @@ スティックをダブルクリックすると Xbox コントローラーと XR ポインターを切り替えられます XR ポインターモード Xbox コントローラーモード + お気に入り + お気に入りはまだありません + ゲームの詳細メニューを開いてお気に入りに追加するか、ゲームにフォーカスした状態でコントローラーの左側フェイスボタンを押します(Xbox は X、PlayStation は四角、Nintendo 配列は Y)。 + すべてのゲームを見る + 表示するお気に入りがありません + 現在の検索やフィルターに一致するか、ライブラリで利用できるお気に入りのゲームはありません。 + お気に入りに追加 + お気に入りから削除 + %1$s をお気に入りに追加 + %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 ca49873b52..bc72121c8b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2312,4 +2312,18 @@ 스틱을 두 번 누르면 Xbox 컨트롤러와 XR 포인터가 전환됩니다 XR 포인터 모드 Xbox 컨트롤러 모드 + 즐겨찾기 + 아직 즐겨찾기가 없습니다 + 게임의 세부 메뉴를 열어 즐겨찾기에 추가하거나, 게임에 포커스한 상태에서 컨트롤러의 왼쪽 페이스 버튼을 누르세요(Xbox는 X, PlayStation은 네모, Nintendo 배열은 Y). + 모든 게임 보기 + 표시할 즐겨찾기가 없습니다 + 현재 검색어나 필터와 일치하거나 라이브러리에서 사용할 수 있는 즐겨찾기 게임이 없습니다. + 즐겨찾기에 추가 + 즐겨찾기에서 제거 + %1$s을(를) 즐겨찾기에 추가 + %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 6c267e0627..91dd569430 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2321,4 +2321,18 @@ Kliknij dwukrotnie gałkę, aby przełączyć między padem Xbox a wskaźnikiem XR Tryb wskaźnika XR Tryb pada Xbox + Ulubione + Brak ulubionych + Otwórz menu szczegółów gry, aby dodać ją do ulubionych, albo naciśnij lewy przycisk główny kontrolera, gdy gra jest zaznaczona (X na Xbox, Kwadrat na PlayStation lub Y w układzie Nintendo). + Przeglądaj wszystkie gry + Brak ulubionych do wyświetlenia + Żadna ulubiona gra nie pasuje do bieżącego wyszukiwania ani filtrów lub nie jest dostępna w bibliotece. + 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 + Dodano do ulubionych + Dodano %1$s 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 7c3db56957..d06609fd05 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2187,4 +2187,18 @@ Clique duas vezes em um analógico para alternar entre controle Xbox e ponteiro XR Modo ponteiro XR Modo controle Xbox + Favoritos + Ainda não há favoritos + Abra o menu de detalhes do jogo para adicioná-lo aos favoritos ou pressione o botão frontal esquerdo do controle enquanto o jogo estiver em foco (X no Xbox, Quadrado no PlayStation ou Y em controles com layout Nintendo). + Ver todos os jogos + Nenhum favorito para mostrar + Nenhum jogo favorito corresponde à busca ou aos filtros atuais ou está disponível na sua biblioteca. + Adicionar aos favoritos + Remover dos favoritos + Adicionar %1$s aos favoritos + Remover %1$s dos favoritos + Removido dos favoritos + %1$s removido dos favoritos + Adicionado aos favoritos + %1$s adicionado aos favoritos diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 02544c0ece..8e40739b8d 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2321,4 +2321,18 @@ Dă dublu clic pe un joystick pentru a comuta între controller Xbox și indicator XR Mod indicator XR Mod controller Xbox + Favorite + Încă nu ai favorite + Deschide meniul de detalii al jocului pentru a-l adăuga la favorite sau apasă butonul frontal din stânga al controlerului când jocul este focalizat (X pe Xbox, Pătrat pe PlayStation sau Y pe un controler cu dispunere Nintendo). + Răsfoiește toate jocurile + Nicio favorită de afișat + Niciun joc favorit nu corespunde căutării sau filtrelor curente sau nu este disponibil în bibliotecă. + 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 + Adăugat la favorite + %1$s adăugat la favorite diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7c8b1b7025..f3bd59b12d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2249,4 +2249,18 @@ https://gamenative.app Дважды нажмите стик, чтобы переключиться между геймпадом Xbox и XR-указателем Режим XR-указателя Режим геймпада Xbox + Избранное + Пока нет избранного + Откройте меню сведений об игре, чтобы добавить её в избранное, или нажмите левую лицевую кнопку контроллера, когда игра выделена (X на Xbox, квадрат на PlayStation или Y при раскладке Nintendo). + Просмотреть все игры + Нет избранного для показа + Нет избранных игр, соответствующих текущему поиску или фильтрам или доступных в вашей библиотеке. + Добавить в избранное + Удалить из избранного + Добавить %1$s в избранное + Удалить %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 94a2c5c8ea..a318d28e43 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2317,4 +2317,18 @@ Двічі натисніть стик, щоб перемкнутися між геймпадом Xbox і XR-вказівником Режим XR-вказівника Режим геймпада Xbox + Вибране + Ще немає вибраного + Відкрийте меню відомостей про гру, щоб додати її до вибраного, або натисніть ліву лицьову кнопку контролера, коли гру виділено (X на Xbox, квадрат на PlayStation або Y з розкладкою Nintendo). + Переглянути всі ігри + Немає вибраного для показу + Немає вибраних ігор, що відповідають поточному пошуку або фільтрам або доступні у вашій бібліотеці. + Додати до вибраного + Видалити з вибраного + Додати %1$s до вибраного + Видалити %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 d3df074f09..dc7e9e9481 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2332,4 +2332,18 @@ 双击摇杆可在 Xbox 手柄与 XR 指针之间切换 XR 指针模式 Xbox 手柄模式 + 收藏 + 还没有收藏 + 打开游戏详情菜单即可添加到收藏,或在游戏获得焦点时按下控制器左侧面键(Xbox 为 X、PlayStation 为方块键、Nintendo 布局为 Y)。 + 浏览所有游戏 + 没有可显示的收藏 + 没有符合当前搜索或筛选条件,或在您的游戏库中可用的收藏游戏。 + 添加到收藏 + 从收藏中移除 + 将 %1$s 添加到收藏 + 将 %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 269fd0c68c..eb6ddf2f55 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2323,4 +2323,18 @@ 雙擊搖桿可在 Xbox 控制器與 XR 指標之間切換 XR 指標模式 Xbox 控制器模式 + 收藏 + 還沒有收藏 + 開啟遊戲詳細資料選單即可加入收藏,或在遊戲取得焦點時按下控制器左側正面按鍵(Xbox 為 X、PlayStation 為方形鍵、Nintendo 配置為 Y)。 + 瀏覽所有遊戲 + 沒有可顯示的收藏 + 沒有符合目前搜尋或篩選條件,或在您的遊戲庫中可用的收藏遊戲。 + 加入收藏 + 從收藏中移除 + 將 %1$s 加入收藏 + 將 %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 caf0e8084c..e424871dff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,6 +110,12 @@ Recommended Add or play some games to get GOG recommendations based on your library. All + Favorites + No favorites yet + Open a game’s details menu to add it to favorites, or press the controller’s west face button while a game is focused (X on Xbox, Square on PlayStation, or Y on Nintendo-style layouts). + Browse all games + No favorites to show + No favorite games match the current search or filters, or are available in your library. Steam GOG Epic @@ -1707,6 +1713,14 @@ Reset container Get support Submit feedback + Favorite + Unfavorite + Favorite %1$s + Unfavorite %1$s + Added to favorites + Added %1$s to favorites + Removed from favorites + Removed %1$s from favorites Reset DRM Use known config Browse community configs 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..e21c68bc4a --- /dev/null +++ b/app/src/test/java/app/gamenative/data/FavoritesUtilsTest.kt @@ -0,0 +1,94 @@ +package app.gamenative.data + +import org.junit.Assert.assertEquals +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 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() { + val count = FavoritesUtils.countPresent( + favorites = setOf("a", "b", "c"), + eligibleIds = emptySet(), + ) + + assertEquals(0, count) + } +}