-
-
Notifications
You must be signed in to change notification settings - Fork 410
Feat/favourite games tab utkarsh #1835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f03773e
32bc9ae
276764f
4fc6646
d9c61f0
3a2599b
5464f4c
9919b9b
5332a05
4378adf
fafefac
9dfbd0e
939c12b
66e358c
57dae7a
469d228
5e333f0
685c9d9
9d12e42
f5b6b5e
a145444
5f5aace
23b22c6
81dbdca
853e180
0edcbf8
48afc0e
b276ac9
074d84e
1e1b390
1bf1309
c299c14
be58b54
d8a4a7f
2bbb0ad
81b3386
4b679e5
1393d47
816b767
4a98dc6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Preferences> | ||
|
|
||
|
|
@@ -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<String> | ||
| get() { | ||
| val value = getPref(FAVORITE_APP_IDS, "[]") | ||
| return try { | ||
| Json.decodeFromString<Set<String>>(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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Invalidate pending favorite writes before clearing preferences. Otherwise a queued Prompt for AI agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The fire-and-forget Prompt for AI agents |
||
| val serialized = Json.encodeToString(value) | ||
| dataStore.edit { pref -> | ||
| val isLatest = synchronized(favoritePersistenceLock) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The last-write-wins persistence path is untested. Add a focused test that forces out-of-order coroutine execution and verifies that only the newest Prompt for AI agents |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Set<String>>(emptySet()) | ||
|
|
||
| val favorites: StateFlow<Set<String>> = _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<Boolean> = _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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When the preference read fails, this marks an unknown set as loaded and permits the next toggle to persist a partial set, deleting previously saved favorites. Retry the read or keep mutations disabled until the saved set is read successfully instead of treating a read failure as empty. Prompt for AI agents |
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When loading finishes before Prompt for AI agents |
||
| } | ||
| } 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 | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package app.gamenative.data | ||
|
|
||
| internal object FavoritesUtils { | ||
|
|
||
| fun apply(current: Set<String>, appId: String, favorite: Boolean): Set<String> = | ||
| if (favorite) current + appId else current - appId | ||
|
|
||
| fun <T> filter(items: List<T>, favorites: Set<String>, id: (T) -> String): List<T> = | ||
| items.filter { id(it) in favorites } | ||
|
|
||
| fun countPresent(favorites: Set<String>, eligibleIds: Set<String>): Int = | ||
| favorites.count { it in eligibleIds } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<String> = emptySet() | ||||||||
|
|
||||||||
| // Complete and unfiltered app list | ||||||||
| private var appList: List<SteamApp> = emptyList() | ||||||||
| private var gogGameList: List<GOGGame> = 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) | ||||||||
|
Comment on lines
+155
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win Serialize Several DAO and favorites collectors can call Protect generation increment, cancellation, launch, and assignment with one lock or one serialized coroutine context. Also applies to: 645-650, 1161-1162 🤖 Prompt for AI Agents |
||||||||
|
|
||||||||
| // 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 -> | ||||||||
|
Comment on lines
+208
to
+209
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents
Suggested change
|
||||||||
| 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), | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When a favorite changes during a non-Favorites filter pass, this pass can overwrite the collector's newer badge count with its old Prompt for AI agents
Suggested change
|
||||||||
| ) | ||||||||
| } | ||||||||
| } | ||||||||
| filterJob = job | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Serialize the Prompt for AI agents |
||||||||
| return job | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Invalidate pending favorite writes when preferences are cleared.
clearPreferences()at Line 83 clears DataStore but does not changefavoritePersistenceVersion. If a favorite write is queued, the clear finishes, and that job then reaches Line 1374, its version check still succeeds and it recreatesfavorite_app_ids.Advance the version under
favoritePersistenceLockbefore the clear operation. This makes every older queued write skip its commit.Proposed fix
fun clearPreferences() { + synchronized(favoritePersistenceLock) { + favoritePersistenceVersion += 1 + } scope.launch { dataStore.edit { it.clear() } } }Also applies to: 1365-1381
🤖 Prompt for AI Agents