Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Previously starred games disappear after updating because this new DataStore key does not read the existing favourite_app_ids value. Retain the established key or migrate its value to the renamed key before treating the new key as authoritative.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/PrefManager.kt, line 1270:

<comment>Previously starred games disappear after updating because this new DataStore key does not read the existing `favourite_app_ids` value. Retain the established key or migrate its value to the renamed key before treating the new key as authoritative.</comment>

<file context>
@@ -1265,21 +1265,21 @@ object PrefManager {
     // work across every source (Steam, GOG, Epic, Amazon, custom games) without needing an account.
-    private val FAVOURITE_APP_IDS = stringPreferencesKey("favourite_app_ids")
-    var favouriteAppIds: Set<String>
+    private val FAVORITE_APP_IDS = stringPreferencesKey("favorite_app_ids")
+    var favoriteAppIds: Set<String>
         get() {
</file context>

var favoriteAppIds: Set<String>
get() {
val value = getPref(FAVORITE_APP_IDS, "[]")
return try {
Json.decodeFromString<Set<String>>(value)
} catch (e: Exception) {
Comment thread
jmarti326 marked this conversation as resolved.
Timber.w(e, "Failed to decode favorite app ids; falling back to empty set")
emptySet()
}
Comment thread
jmarti326 marked this conversation as resolved.
}
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
Expand Down
90 changes: 90 additions & 0 deletions app/src/main/java/app/gamenative/data/FavoritesManager.kt
Original file line number Diff line number Diff line change
@@ -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<Set<String>>(emptySet())

/** The set of favorited app ids. Observe this to react to changes. */
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()

/** Edits made before the saved set finished loading, kept so they can be replayed on top of it. */
private val pendingEdits = LinkedHashMap<String, Boolean>()

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Cold-start saved favorites can still briefly render as unstarred/empty and trigger the star-pop path because the two independent flows do not provide an atomic snapshot. Expose and collect one load-state value containing both the ids and readiness flag, rather than using loaded as a separate completion signal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/data/FavoritesManager.kt, line 62:

<comment>Cold-start saved favorites can still briefly render as unstarred/empty and trigger the star-pop path because the two independent flows do not provide an atomic snapshot. Expose and collect one load-state value containing both the ids and readiness flag, rather than using `loaded` as a separate completion signal.</comment>

<file context>
@@ -47,8 +55,11 @@ object FavoritesManager {
+                // 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.
</file context>

// 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
}
}
}
}
Comment thread
jmarti326 marked this conversation as resolved.

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
}
}
}
}
26 changes: 26 additions & 0 deletions app/src/main/java/app/gamenative/data/FavoritesUtils.kt
Original file line number Diff line number Diff line change
@@ -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<String>, appId: String, favorite: Boolean): Set<String> =
if (favorite) current + appId else current - appId

/** Keeps only the [items] whose id (via [id]) is in [favorites], preserving order. */
fun <T> filter(items: List<T>, favorites: Set<String>, id: (T) -> String): List<T> =
items.filter { id(it) in favorites }

/** Counts how many of the [items] are in [favorites]. */
fun <T> count(items: List<T>, favorites: Set<String>, 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<String>, eligibleIds: Set<String>): Int =
favorites.count { it in eligibleIds }
}
63 changes: 49 additions & 14 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Boolean>? = null
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Undo becomes unreachable if the Manage Mods overlay takes ownership while its snackbar is visible: NexusDialogSnackbarHost renders only the message. Render the action there too, or share this action-capable snackbar renderer across hosts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/PluviaMain.kt, line 1592:

<comment>Undo becomes unreachable if the Manage Mods overlay takes ownership while its snackbar is visible: `NexusDialogSnackbarHost` renders only the message. Render the action there too, or share this action-capable snackbar renderer across hosts.</comment>

<file context>
@@ -1566,12 +1574,31 @@ fun PluviaMain(
+                                    color = MaterialTheme.colorScheme.onSurface,
+                                    style = MaterialTheme.typography.bodyMedium,
+                                )
+                                if (actionLabel != null) {
+                                    TextButton(onClick = { data.performAction() }) {
+                                        Text(
</file context>

TextButton(onClick = { data.performAction() }) {
Text(
text = actionLabel,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
)
}
}
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/ui/data/LibraryState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ data class LibraryState(
val epicCount: Int = 0,
val amazonCount: Int = 0,
val localCount: Int = 0,
val favoritesCount: Int = 0,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
9 changes: 9 additions & 0 deletions app/src/main/java/app/gamenative/ui/enums/LibraryTab.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading