-
-
Notifications
You must be signed in to change notification settings - Fork 357
Add a Favourites tab and star toggle to the library #1712
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
base: master
Are you sure you want to change the base?
Changes from all commits
3e80cbd
2081e3d
33ff375
0db1c68
71acc94
0f5f40d
b9a0c66
25cc20d
9e38abd
8533f27
a669f55
f477a3a
8b3f5eb
2cf3756
d022968
65ddad3
2391258
c052139
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 |
|---|---|---|
| @@ -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 | ||
|
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. 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 Prompt for AI agents |
||
| // 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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
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 | ||
| } | ||
| } | ||
| } | ||
| } | ||
| 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 } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import android.content.Intent | |
| import androidx.activity.compose.BackHandler | ||
| import androidx.compose.foundation.isSystemInDarkTheme | ||
| import androidx.compose.foundation.layout.Box | ||
| import androidx.compose.foundation.layout.Row | ||
| import androidx.compose.foundation.layout.WindowInsets | ||
| import androidx.compose.foundation.layout.fillMaxSize | ||
| import androidx.compose.foundation.layout.fillMaxWidth | ||
|
|
@@ -16,8 +17,10 @@ import androidx.compose.foundation.layout.windowInsetsPadding | |
| import androidx.compose.foundation.shape.RoundedCornerShape | ||
| import androidx.compose.material3.MaterialTheme | ||
| import androidx.compose.material3.SnackbarHost | ||
| import androidx.compose.material3.SnackbarResult | ||
| import androidx.compose.material3.Surface | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.material3.TextButton | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.DisposableEffect | ||
| import androidx.compose.runtime.LaunchedEffect | ||
|
|
@@ -32,6 +35,7 @@ import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.platform.LocalContext | ||
| import androidx.compose.ui.platform.LocalUriHandler | ||
| import androidx.compose.ui.text.style.TextOverflow | ||
| import androidx.compose.ui.unit.dp | ||
| import androidx.compose.ui.zIndex | ||
| import androidx.hilt.navigation.compose.hiltViewModel | ||
|
|
@@ -133,7 +137,7 @@ import kotlinx.coroutines.withTimeoutOrNull | |
| import timber.log.Timber | ||
|
|
||
| private const val PENDING_LAUNCH_TIMEOUT_MS = 10_000L | ||
| private const val SNACKBAR_SHOW_TIMEOUT_MS = 15_000L | ||
| private const val SNACKBAR_SHOW_TIMEOUT_MS = 6_000L | ||
|
|
||
| /** Used to suspend preLaunchApp while the user decides on large workshop updates. */ | ||
| private var workshopUpdateDeferred: CompletableDeferred<Boolean>? = null | ||
|
|
@@ -1127,13 +1131,17 @@ fun PluviaMain( | |
| var exitSnackbarVisible by remember { mutableStateOf(false) } | ||
|
|
||
| LaunchedEffect(snackbarController) { | ||
| SnackbarManager.messages.collect { message -> | ||
| if ( | ||
| withTimeoutOrNull(SNACKBAR_SHOW_TIMEOUT_MS) { | ||
| snackbarController.hostState.showSnackbar(message) | ||
| } == null | ||
| ) { | ||
| Timber.w("[Snackbar]: Display timed out before dismissal") | ||
| SnackbarManager.events.collect { event -> | ||
| val result = withTimeoutOrNull(SNACKBAR_SHOW_TIMEOUT_MS) { | ||
| snackbarController.hostState.showSnackbar( | ||
| message = event.message, | ||
| actionLabel = event.actionLabel, | ||
| ) | ||
| } | ||
| when { | ||
| result == null -> Timber.w("[Snackbar]: Display timed out before dismissal") | ||
| result == SnackbarResult.ActionPerformed -> event.onAction?.invoke() | ||
| else -> {} | ||
| } | ||
| // snackbar dismissed (timeout or new message) — reset exit flag | ||
| exitSnackbarVisible = false | ||
|
|
@@ -1561,17 +1569,44 @@ fun PluviaMain( | |
| modifier = Modifier.fillMaxWidth(), | ||
| contentAlignment = Alignment.BottomCenter, | ||
| ) { | ||
| val actionLabel = data.visuals.actionLabel | ||
| Surface( | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .padding(horizontal = 16.dp), | ||
| shape = RoundedCornerShape(24.dp), | ||
| color = MaterialTheme.colorScheme.surfaceContainerHigh, | ||
| shadowElevation = 4.dp, | ||
| ) { | ||
| Text( | ||
| text = data.visuals.message, | ||
| modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), | ||
| color = MaterialTheme.colorScheme.onSurface, | ||
| style = MaterialTheme.typography.bodyMedium, | ||
| ) | ||
| Row( | ||
| verticalAlignment = Alignment.CenterVertically, | ||
| modifier = Modifier.padding( | ||
| start = 24.dp, | ||
| end = if (actionLabel != null) 8.dp else 24.dp, | ||
| ), | ||
| ) { | ||
| Text( | ||
| text = data.visuals.message, | ||
| modifier = Modifier | ||
| .weight(1f) | ||
| .padding(vertical = 12.dp), | ||
| color = MaterialTheme.colorScheme.onSurface, | ||
| style = MaterialTheme.typography.bodyMedium, | ||
| maxLines = 2, | ||
| overflow = TextOverflow.Ellipsis, | ||
| ) | ||
| if (actionLabel != null) { | ||
|
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: Undo becomes unreachable if the Manage Mods overlay takes ownership while its snackbar is visible: Prompt for AI agents |
||
| TextButton(onClick = { data.performAction() }) { | ||
| Text( | ||
| text = actionLabel, | ||
| color = MaterialTheme.colorScheme.primary, | ||
| style = MaterialTheme.typography.labelLarge, | ||
| maxLines = 1, | ||
| softWrap = false, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
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.
P1: Previously starred games disappear after updating because this new DataStore key does not read the existing
favourite_app_idsvalue. Retain the established key or migrate its value to the renamed key before treating the new key as authoritative.Prompt for AI agents