Add a Favourites tab and star toggle to the library - #1712
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds persisted favourite game IDs, reactive favourite filtering and counts, favourite menu and card controls, a favourites library tab, utility tests, and localized strings. ChangesFavourites feature
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant FavouriteStarButton
participant FavouritesManager
participant PrefManager
participant LibraryViewModel
User->>FavouriteStarButton: tap star
FavouriteStarButton->>FavouritesManager: toggle(appId)
FavouritesManager->>PrefManager: persist favouriteAppIds
FavouritesManager-->>LibraryViewModel: emit updated favourites
LibraryViewModel->>LibraryViewModel: refilter library and recompute count
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Hey there, thanks for contributing. How come you couldn't build locally? |
My environment got messed up, so I triggered a GitHub Action to generate a build while I worked through the issue. Everything is working now. Sorry for any confusion. |
|
Quick status update. The feature is complete on my side and now has unit tests. I pulled the favourites logic (add, remove, toggle, filter and count) into a small I ran the same unit test tasks the PR check uses ( Happy to adjust anything if you would rather I take a different approach. Thanks for taking a look. |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/main/java/app/gamenative/PrefManager.kt`:
- Around line 1274-1278: Update the JSON decoding catch block in the preferences
deserialization flow to log the caught exception with a one-line Timber.w call
before returning emptySet(). Preserve the existing fallback behavior and follow
the same logging pattern used by customGamePaths and customGameManualFolders.
In `@app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt`:
- Around line 172-180: Update the FavouritesManager.favourites observer to
serialize refilter operations by replacing collect with collectLatest, or
otherwise canceling the prior onFilterApps job before starting the next. Ensure
onFilterApps(paginationCurrentPage) cannot overlap and stale favourites
snapshots cannot overwrite the latest state.
- Around line 937-946: The favouritesCount calculation in LibraryViewModel
should use the same unfiltered source set as the FAVOURITES tab. Remove the
currentState.show*InLibrary conditions from the buildList while retaining each
source’s credential checks where applicable, so favourites from hidden library
sources remain included.
In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt`:
- Around line 724-738: Update getFavouriteOption to collect
FavouritesManager.favourites with collectAsStateWithLifecycle(), then derive
isFavourite by checking the collected set for libraryItem.appId. Use that
reactive flag for both the menu option type and toggle action so the label
updates when favourites change.
In
`@app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt`:
- Around line 5-6: Replace the invalid StarOutline import and its usage in
FavouriteStarButton with a supported AndroidX Compose star icon variant or an
existing custom vector asset, keeping the favourite-state visual behavior
intact.
In
`@app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt`:
- Around line 311-318: Update the LibraryGridCard layout around
FavouriteStarButton so the star no longer overlays GridStatusIcons or
title/stats content. Place it in a dedicated bottom slot or reserve its full
clickable width in the existing layout, while preserving its bottom-end
alignment and current sizing.
In
`@app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt`:
- Around line 202-205: Update ListViewCard so FavouriteStarButton is rendered
only when appInfo.isRecommended is false, matching the existing guard in
GridViewCard; keep the button’s current appId and iconSize unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 654c6418-7ecc-43a6-9769-7463bbc9bb46
📒 Files selected for processing (29)
app/src/main/java/app/gamenative/PrefManager.ktapp/src/main/java/app/gamenative/data/FavouritesManager.ktapp/src/main/java/app/gamenative/data/FavouritesUtils.ktapp/src/main/java/app/gamenative/ui/data/LibraryState.ktapp/src/main/java/app/gamenative/ui/enums/AppOptionMenuType.ktapp/src/main/java/app/gamenative/ui/enums/LibraryTab.ktapp/src/main/java/app/gamenative/ui/model/LibraryViewModel.ktapp/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.ktapp/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.ktapp/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.ktapp/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.ktapp/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.ktapp/src/main/res/values-da/strings.xmlapp/src/main/res/values-de/strings.xmlapp/src/main/res/values-es/strings.xmlapp/src/main/res/values-fr/strings.xmlapp/src/main/res/values-it/strings.xmlapp/src/main/res/values-ja/strings.xmlapp/src/main/res/values-ko/strings.xmlapp/src/main/res/values-pl/strings.xmlapp/src/main/res/values-pt-rBR/strings.xmlapp/src/main/res/values-ro/strings.xmlapp/src/main/res/values-ru/strings.xmlapp/src/main/res/values-uk/strings.xmlapp/src/main/res/values-zh-rCN/strings.xmlapp/src/main/res/values-zh-rTW/strings.xmlapp/src/main/res/values/strings.xmlapp/src/test/java/app/gamenative/data/FavouritesUtilsTest.kt
| // Re-filter whenever the set of favourite games changes, so the Favourites tab and the | ||
| // tab badge stay in sync as the user stars or unstars games. | ||
| viewModelScope.launch(Dispatchers.IO) { | ||
| FavouritesManager.favourites | ||
| .drop(1) | ||
| .collect { | ||
| onFilterApps(paginationCurrentPage) | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the relevant file and inspect nearby declarations
ast-grep outline app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt --view expanded
echo
echo "---- focused lines ----"
sed -n '1,260p' app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt | nl -ba | sed -n '140,230p'
echo
echo "---- find onFilterApps and favourites flow usage ----"
rg -n "onFilterApps|FavouritesManager\.favourites|collectLatest|drop\(1\)" app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt app/src/main/java -SRepository: utkarshdalal/GameNative
Length of output: 2605
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "---- favourites collector and nearby state ----"
sed -n '160,200p' app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
echo
echo "---- onFilterApps implementation ----"
sed -n '500,690p' app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt
echo
echo "---- any Job handling around filtering ----"
rg -n "searchDebounceJob|Job\?|launch\(|cancel\(|onFilterApps\(" app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt -SRepository: utkarshdalal/GameNative
Length of output: 13304
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "---- favourites references in the ViewModel ----"
rg -n "FavouritesManager|favourite|favorites" app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt -S
echo
echo "---- tail of onFilterApps ----"
sed -n '690,920p' app/src/main/java/app/gamenative/ui/model/LibraryViewModel.ktRepository: utkarshdalal/GameNative
Length of output: 11927
Serialize favourites-triggered refilters. FavouritesManager.favourites.collect { onFilterApps(...) } can start overlapping viewModelScope jobs, and onFilterApps snapshots FavouritesManager.favourites.value before writing _state at the end. A slower earlier run can still finish last and replace the Favourites tab list/counts with a stale snapshot; switch this collector to collectLatest or cancel the previous filter job before launching a new one.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt` around lines
172 - 180, Update the FavouritesManager.favourites observer to serialize
refilter operations by replacing collect with collectLatest, or otherwise
canceling the prior onFilterApps job before starting the next. Ensure
onFilterApps(paginationCurrentPage) cannot overlap and stale favourites
snapshots cannot overwrite the latest state.
| import androidx.compose.material.icons.filled.Star | ||
| import androidx.compose.material.icons.filled.StarOutline |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n 'androidx\.compose\.material\.icons\.(filled|outlined)\.(StarOutline|Star)' app/src/main
rg -n 'material-icons(-extended)?' --glob 'build.gradle*' --glob 'libs.versions.toml' .Repository: utkarshdalal/GameNative
Length of output: 1181
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n-- FavouriteStarButton.kt --\n'
sed -n '1,120p' app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt
printf '\n-- GameOptionsPanel.kt --\n'
sed -n '45,90p' app/src/main/java/app/gamenative/ui/screen/library/components/GameOptionsPanel.kt
printf '\n-- GameFeedbackDialog.kt --\n'
sed -n '1,70p' app/src/main/java/app/gamenative/ui/component/dialog/GameFeedbackDialog.kt
printf '\n-- libs.versions.toml (compose icons) --\n'
sed -n '45,60p' gradle/libs.versions.tomlRepository: utkarshdalal/GameNative
Length of output: 8691
🌐 Web query:
AndroidX Compose material icons filled StarOutline Icons.Filled.StarOutline reference material-icons-extended
💡 Result:
In Android Jetpack Compose, the standard androidx.compose.material:material-icons-core library and the androidx.compose.material:material-icons-extended library provide access to various Material icons [1][2]. Regarding the specific icon you requested: 1. Icons.Filled.StarOutline: This icon is typically not available directly as a standard Compose ImageVector constant in the Material library. The standard Icons.Filled.Star represents a filled star [1][3]. 2. Accessing Star Outline: To display a "star outline" in Compose, you have a few common options: - Use Icons.Outlined.Star: Note that in the standard Material Icons set, Icons.Outlined.Star is sometimes a filled star with an outline, rather than an empty star outline [4]. - Use Icons.Filled.StarBorder (if available in your version of the extended library) or import a custom SVG/Vector asset for the specific "star outline" look you desire [4]. - Manual Import: If the specific version of "Star Outline" you need is missing from the material-icons-extended library, the recommended approach is to download the SVG from the official Material Symbols website and import it as a Vector Asset into your Android Studio project [4][2]. 3. Material Icons Extended: While the material-icons-extended library includes a much larger set of icons than material-icons-core, it does not contain every single icon variation available in Material Symbols [2]. When an icon is missing from this dependency, adding a custom vector asset is the standard solution [4][2]. To use the extended icons, ensure you have the following dependency in your build.gradle file: implementation "androidx.compose.material:material-icons-extended:1.x.x" (replace with your compose version) [2].
Citations:
- 1: https://developer.android.com/reference/kotlin/androidx/compose/material/icons/Icons.Filled
- 2: https://stackoverflow.com/questions/65665563/android-jetpack-compose-icons-doesnt-contain-some-of-the-material-icons
- 3: https://developer.android.com/reference/kotlin/androidx/compose/material/icons/filled/package-summary
- 4: https://stackoverflow.com/questions/74050270/compose-icons-outlined-star-isnt-outlined
Use a valid Compose star icon. Icons.Filled.StarOutline isn’t part of the AndroidX Compose Material icon set, so this import/use site will fail to compile. Replace it with a supported icon variant or a custom vector asset.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@app/src/main/java/app/gamenative/ui/screen/library/components/FavouriteStarButton.kt`
around lines 5 - 6, Replace the invalid StarOutline import and its usage in
FavouriteStarButton with a supported AndroidX Compose star icon variant or an
existing custom vector asset, keeping the favourite-state visual behavior
intact.
There was a problem hiding this comment.
All reported issues were addressed across 29 files
Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/main/java/app/gamenative/data/FavouritesManager.kt`:
- Around line 41-61: Update the FavouritesManager init persistence flow and the
loaded setFavourite/toggle persistence path to perform PrefManager reads and
writes while holding lock. Use the live _favourites.value for comparisons and
writes rather than the captured merged snapshot, ensuring persistence decisions
are serialized with the merge and concurrent edits cannot be overwritten.
- Around line 41-61: The FavouritesManager init load must always mark the
manager as loaded even when PrefManager.favouriteAppIds throws. Update the
coroutine around the preference read to handle the failure or use a finally
block that sets loaded under lock, while preserving pending-edit application and
normal persistence behavior on successful reads.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 02ad5b23-0097-45dd-8493-4fc1eafc07e1
📒 Files selected for processing (1)
app/src/main/java/app/gamenative/data/FavouritesManager.kt
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/data/FavouritesManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/data/FavouritesManager.kt:53">
P2: If the preference read (`PrefManager.favouriteAppIds`) throws — e.g., due to a corrupted DataStore or an I/O error — this `launch` aborts before `loaded = true` is reached. That leaves `setFavourite`/`toggle` permanently stuck on the pending-edits path with no persistence for the rest of the process. Consider wrapping the load in a try/catch that sets `loaded = true` in a `finally` block (failing open with an empty set), or adding a retry, so the system degrades gracefully.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| result = FavouritesUtils.apply(result, appId, favourite) | ||
| } | ||
| pendingEdits.clear() | ||
| loaded = true |
There was a problem hiding this comment.
P2: If the preference read (PrefManager.favouriteAppIds) throws — e.g., due to a corrupted DataStore or an I/O error — this launch aborts before loaded = true is reached. That leaves setFavourite/toggle permanently stuck on the pending-edits path with no persistence for the rest of the process. Consider wrapping the load in a try/catch that sets loaded = true in a finally block (failing open with an empty set), or adding a retry, so the system degrades gracefully.
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/FavouritesManager.kt, line 53:
<comment>If the preference read (`PrefManager.favouriteAppIds`) throws — e.g., due to a corrupted DataStore or an I/O error — this `launch` aborts before `loaded = true` is reached. That leaves `setFavourite`/`toggle` permanently stuck on the pending-edits path with no persistence for the rest of the process. Consider wrapping the load in a try/catch that sets `loaded = true` in a `finally` block (failing open with an empty set), or adding a retry, so the system degrades gracefully.</comment>
<file context>
@@ -32,16 +32,31 @@ object FavouritesManager {
+ result = FavouritesUtils.apply(result, appId, favourite)
+ }
+ pendingEdits.clear()
+ loaded = true
+ merged = result
+ _favourites.value = result
</file context>
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
1 issue found across 30 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/PrefManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/PrefManager.kt:1270">
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.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
|
||
| // 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") |
There was a problem hiding this comment.
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>
73a778f to
40ead44
Compare
Adds a simple way to mark games as favourites and find them again quickly. - Store favourites locally as a set of appIds, so they work across every source (Steam, GOG, Epic, Amazon, custom games) without needing a login - Add FavouritesManager, which exposes the set as a StateFlow so the list and the game cards stay in sync as favourites change - Add a Favourites tab to the library, with a count badge - Add a star toggle on the list and grid cards - Add an Add/Remove favourite entry to the game options menu on the detail screen, so it is available for every source in one place - Add strings for the default locale and the existing translations
Move the pure favourites logic (add, remove, toggle, filter, count) into a small FavouritesUtils helper so it can be unit tested on its own, mirroring the existing LibrarySortUtils pattern. FavouritesManager and LibraryViewModel now delegate to it. Adds FavouritesUtilsTest covering the add/remove/toggle transitions and the library filter and count behaviour.
Use MutableStateFlow.updateAndGet so the read-modify-write happens in one step. This avoids a lost update if setFavourite is ever called from more than one thread, and we only write to PrefManager when the set actually changed.
Building the singleton no longer does a synchronous DataStore read in its initializer, so the first card or detail menu that touches it does not block the UI thread on disk. The saved set now loads on Dispatchers.IO and fills the StateFlow when it returns. A small guard skips that load if the user has already toggled a favourite, so an early edit cannot be overwritten.
The async load could drop previously saved favourites if the user starred a game before the disk read returned: the toggle applied on top of an empty set and then persisted. Pre-load edits are now recorded and replayed on top of the stored set once it loads, so nothing saved is lost.
The badge counted favourites using the user's per-source library visibility preferences, but the Favourites tab shows favourites from every source (gated only by credentials). If a source was hidden from the library the badge could undercount what the tab actually lists. The count now uses the same source logic as the tab.
The post-load persistence write happened outside the lock and compared against a stale snapshot, so a toggle racing with the initial load could be silently dropped. The flow update and the persist now happen together under the same lock in both the loader and setFavourite, so whichever holds the lock last writes the latest set.
Read the favourites set as Compose state in getFavouriteOption so the Add/Remove label reflects the current state when the menu is reopened after toggling, instead of a value captured once.
Recommended entries use synthetic ids that don't map to a real favourite, so hide the star in the list card to match the grid card which already skips it for recommended items.
Warn via Timber when the saved favourite ids can't be decoded instead of silently returning an empty set, so a corrupt value is easier to notice and diagnose.
Nothing calls FavouritesUtils.toggle; the toggle behaviour lives in FavouritesManager, which builds on apply(). Drop the helper and its test to avoid dead code.
Normalise the spelling to en-US: FavoritesManager/FavoritesUtils/ FavoriteStarButton, the PrefManager favoriteAppIds key, enum entries, the FAVORITES tab, and the English strings. Non-English translations keep their existing wording; only the string resource key names change. The stored preference key becomes favorite_app_ids (the feature is not yet released, so there is no saved data to migrate).
40ead44 to
f477a3a
Compare
- Add a dedicated Favorites tab empty state with a "no favorites yet" variant (with a CTA back to All) and a filtered/unavailable variant, localized across all 14 supported languages. - Expose FavoritesManager.loaded so the UI distinguishes "loaded empty" from "not yet loaded", publishing the loaded set before flipping the flag to avoid an empty-state/star flash. - Only recompute the badge (not a full library re-filter) when favorites change while off the Favorites tab, removing needless rebuild/flicker. - Clamp pagination to the valid range so removing favorites can't leave the pager on a page past the last one. - Extract FavoritesUtils.countPresent and add unit tests.
Add controller/D-pad focus behavior, a legibility scrim on cover art, and contextual accessibility labels to the favourite star button, plus an Undo snackbar when a favourite is removed. - SnackbarManager gains an optional action + callback (Undo); PluviaMain invokes the callback on SnackbarResult.ActionPerformed. - FavoriteStarButton now owns its interaction source and draws a focusRing, sits on a translucent circular scrim when overlaid on cover art (48dp touch target preserved), and uses named a11y labels (Add/Remove <game>). - Removal from the grid/list star and the options menu shows an Undo snackbar that re-adds the game. - New strings (favorite_add_named, favorite_remove_named, favorite_removed, favorite_removed_named, undo) localized across all 14 locales.
- FavoriteStarButton plays a spring 'pop' scale when a game is favourited (only on user toggles, not when an already-favourited card scrolls in) and fires haptic feedback on tap. - Halve the snackbar display cap (SNACKBAR_SHOW_TIMEOUT_MS) from 15s to 7.5s so toasts, including the favourite Undo, dismiss faster.
Address issues found in review of the favourites polish: - The custom SnackbarHost only rendered the message Text, so the Undo action was never shown or reachable (touch, controller, or screen reader). Render the action as a focusable TextButton that calls performAction(). - The pop animation's 'first composition' guard was defeated by the async favorites load: every already-favourited card popped at once when the set finished loading. Gate the animation on FavoritesManager.loaded and only animate a genuine false->true transition observed after load. - Replace the heavy LongPress haptic on the toggle with a light CONTEXT_CLICK tick, which better suits a quick favourite toggle.
There was a problem hiding this comment.
8 issues found across 28 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteActions.kt:18">
P1: A cold-start tap can silently remove a stored favorite: the UI and `wasFavorite` can still be false when loading flips the manager to true, then `toggle()` removes it without showing Undo. Apply the state observed for this click rather than reading the manager again.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt:87">
P3: Quickly removing a just-added favorite can leave the outlined star permanently scaled down until the card is recreated. Reset `scale` when the state becomes false so cancellation of the pop animation cannot preserve its intermediate value.</violation>
<violation number="2" location="app/src/main/java/app/gamenative/ui/screen/library/components/FavoriteStarButton.kt:127">
P3: The image-overlay scrim ignores theme-provided `scrim` color, so custom color schemes cannot keep this control visually consistent. Use the shared Material color instead of a hardcoded black.
(Based on your team's feedback about hardcoded UI colors.) [FEEDBACK_USED].</violation>
</file>
<file name="app/src/main/res/values-ru/strings.xml">
<violation number="1" location="app/src/main/res/values-ru/strings.xml:2042">
P3: The removal snackbar can be grammatically incorrect for many game titles because `удалено` must agree with `%1$s`. Use an impersonal construction that places the title after the action.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/util/SnackbarManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/util/SnackbarManager.kt:26">
P3: An action callback can be silently dropped when a caller passes `null` for `actionLabel`, because the snackbar host renders no action control in that case. Make the action-overload label non-null so every accepted callback is reachable.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/PluviaMain.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/PluviaMain.kt:1592">
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.</violation>
</file>
<file name="app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt">
<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt:998">
P2: Controller users cannot reach “Browse all games” from an empty Favorites tab: DPAD_DOWN from the tab bar is consumed, but empty lists are not considered focusable. Route empty-state down navigation to the action (or focus it explicitly) so the recovery action is usable without touch.</violation>
</file>
<file name="app/src/main/java/app/gamenative/data/FavoritesManager.kt">
<violation number="1" location="app/src/main/java/app/gamenative/data/FavoritesManager.kt:62">
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.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| */ | ||
| internal fun toggleFavoriteWithUndo(context: Context, appId: String, gameName: String?) { | ||
| val wasFavorite = FavoritesManager.isFavorite(appId) | ||
| FavoritesManager.toggle(appId) |
There was a problem hiding this comment.
P1: A cold-start tap can silently remove a stored favorite: the UI and wasFavorite can still be false when loading flips the manager to true, then toggle() removes it without showing Undo. Apply the state observed for this click rather than reading the manager again.
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/screen/library/components/FavoriteActions.kt, line 18:
<comment>A cold-start tap can silently remove a stored favorite: the UI and `wasFavorite` can still be false when loading flips the manager to true, then `toggle()` removes it without showing Undo. Apply the state observed for this click rather than reading the manager again.</comment>
<file context>
@@ -0,0 +1,31 @@
+ */
+internal fun toggleFavoriteWithUndo(context: Context, appId: String, gameName: String?) {
+ val wasFavorite = FavoritesManager.isFavorite(appId)
+ FavoritesManager.toggle(appId)
+ if (wasFavorite) {
+ val message = if (gameName.isNullOrBlank()) {
</file context>
| color = MaterialTheme.colorScheme.onSurface, | ||
| style = MaterialTheme.typography.bodyMedium, | ||
| ) | ||
| if (actionLabel != null) { |
There was a problem hiding this comment.
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>
| ) | ||
| } else if (showFavoritesEmptyState) { | ||
| if (favorites.isEmpty()) { | ||
| LibraryFavoritesEmptyState( |
There was a problem hiding this comment.
P2: Controller users cannot reach “Browse all games” from an empty Favorites tab: DPAD_DOWN from the tab bar is consumed, but empty lists are not considered focusable. Route empty-state down navigation to the action (or focus it explicitly) so the recovery action is usable without touch.
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/screen/library/LibraryScreen.kt, line 998:
<comment>Controller users cannot reach “Browse all games” from an empty Favorites tab: DPAD_DOWN from the tab bar is consumed, but empty lists are not considered focusable. Route empty-state down navigation to the action (or focus it explicitly) so the recovery action is usable without touch.</comment>
<file context>
@@ -984,6 +993,24 @@ private fun LibraryScreenContent(
)
+ } else if (showFavoritesEmptyState) {
+ if (favorites.isEmpty()) {
+ LibraryFavoritesEmptyState(
+ titleResId = R.string.favorites_empty_title,
+ messageResId = R.string.favorites_empty_message,
</file context>
| <string name="favorite_add_named">Добавить %1$s в избранное</string> | ||
| <string name="favorite_remove_named">Удалить %1$s из избранного</string> | ||
| <string name="favorite_removed">Удалено из избранного</string> | ||
| <string name="favorite_removed_named">%1$s удалено из избранного</string> |
There was a problem hiding this comment.
P3: The removal snackbar can be grammatically incorrect for many game titles because удалено must agree with %1$s. Use an impersonal construction that places the title after the action.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/res/values-ru/strings.xml, line 2042:
<comment>The removal snackbar can be grammatically incorrect for many game titles because `удалено` must agree with `%1$s`. Use an impersonal construction that places the title after the action.</comment>
<file context>
@@ -2027,8 +2027,18 @@ https://gamenative.app
+ <string name="favorite_add_named">Добавить %1$s в избранное</string>
+ <string name="favorite_remove_named">Удалить %1$s из избранного</string>
+ <string name="favorite_removed">Удалено из избранного</string>
+ <string name="favorite_removed_named">%1$s удалено из избранного</string>
+ <string name="undo">Отменить</string>
</resources>
</file context>
| <string name="favorite_removed_named">%1$s удалено из избранного</string> | |
| <string name="favorite_removed_named">Удалено из избранного: %1$s</string> |
|
|
||
| fun show(message: String) = show(Event(message)) | ||
|
|
||
| fun show(message: String, actionLabel: String?, onAction: () -> Unit) = |
There was a problem hiding this comment.
P3: An action callback can be silently dropped when a caller passes null for actionLabel, because the snackbar host renders no action control in that case. Make the action-overload label non-null so every accepted callback is reachable.
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/util/SnackbarManager.kt, line 26:
<comment>An action callback can be silently dropped when a caller passes `null` for `actionLabel`, because the snackbar host renders no action control in that case. Make the action-overload label non-null so every accepted callback is reachable.</comment>
<file context>
@@ -8,11 +8,26 @@ import kotlinx.coroutines.flow.receiveAsFlow
+
+ fun show(message: String) = show(Event(message))
+
+ fun show(message: String, actionLabel: String?, onAction: () -> Unit) =
+ show(Event(message, actionLabel, onAction))
+
</file context>
| // 48dp touch target the surrounding IconButton already provides. | ||
| Box( | ||
| modifier = Modifier | ||
| .background(Color.Black.copy(alpha = 0.32f), CircleShape) |
There was a problem hiding this comment.
P3: The image-overlay scrim ignores theme-provided scrim color, so custom color schemes cannot keep this control visually consistent. Use the shared Material color instead of a hardcoded black.
(Based on your team's feedback about hardcoded UI colors.) .
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/screen/library/components/FavoriteStarButton.kt, line 127:
<comment>The image-overlay scrim ignores theme-provided `scrim` color, so custom color schemes cannot keep this control visually consistent. Use the shared Material color instead of a hardcoded black.
(Based on your team's feedback about hardcoded UI colors.) .</comment>
<file context>
@@ -41,17 +65,72 @@ internal fun FavoriteStarButton(
+ // 48dp touch target the surrounding IconButton already provides.
+ Box(
+ modifier = Modifier
+ .background(Color.Black.copy(alpha = 0.32f), CircleShape)
+ .padding(4.dp),
+ ) {
</file context>
| .background(Color.Black.copy(alpha = 0.32f), CircleShape) | |
| .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f), CircleShape) |
| if (!loaded) return@LaunchedEffect | ||
| val previous = lastFavoriteState | ||
| lastFavoriteState = isFavorite | ||
| if (previous == false && isFavorite) { |
There was a problem hiding this comment.
P3: Quickly removing a just-added favorite can leave the outlined star permanently scaled down until the card is recreated. Reset scale when the state becomes false so cancellation of the pop animation cannot preserve its intermediate value.
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/screen/library/components/FavoriteStarButton.kt, line 87:
<comment>Quickly removing a just-added favorite can leave the outlined star permanently scaled down until the card is recreated. Reset `scale` when the state becomes false so cancellation of the pop animation cannot preserve its intermediate value.</comment>
<file context>
@@ -41,17 +65,72 @@ internal fun FavoriteStarButton(
+ if (!loaded) return@LaunchedEffect
+ val previous = lastFavoriteState
+ lastFavoriteState = isFavorite
+ if (previous == false && isFavorite) {
+ scale.snapTo(0.6f)
+ scale.animateTo(
</file context>
| // 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 |
There was a problem hiding this comment.
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>
The message text was unconstrained, so it consumed the row width and squeezed the Undo action into a vertical sliver (or pushed it off-screen for long game names). Make the snackbar fill width with side margins, give the message a weight so it shares space and ellipsizes at two lines, and keep the action label on a single line so Undo always renders at its natural width.
7.5s felt too long; reduce SNACKBAR_SHOW_TIMEOUT_MS to 6000ms.
Description
This adds a simple way to mark games as favourites so you can get back to them without scrolling the whole library. It follows the backlog card "Add Favourite Tag & tab in GN". I know new features are meant to go through
#code-changesfirst, so please treat this as a starting point and I'm happy to change or drop any of it.I kept it deliberately small, one star toggle rather than full tagging. Most of the effort went into making it behave well and feel right with both touch and a controller.
What it does:
appIdvalues, so they work across Steam, GOG, Epic, Amazon and custom games without needing a login.FavoritesManagerholds the set as aStateFlowso the tab and the cards stay in sync. It loads off the main thread and saves atomically, and if you star something while it is still loading that edit gets replayed on top instead of being lost.FavoritesUtilskeeps the pure logic (add, remove, filter, count) in one place with unit tests.A couple of things I am not sure about:
Recording
Demo
Watch the screen recording (opens in GitHub's video viewer)
Screenshots
Favourites tab in grid view. Star on each card and the count in the tab.
Favourites tab in list view.
A favourite highlighted in the carousel view.
The empty state, before anything is starred.
Type of Change
Checklist
#code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.CONTRIBUTING.md.Summary by cubic
Adds a Favorites tab and a star toggle so you can mark games and find them fast across all sources. Adds an empty state and Undo for removals, with instant, safe updates even during initial load; the tab and badge stay in sync and snackbars now auto-dismiss after 6s.
New Features
PrefManageras a set ofappIds; works for Steam, GOG, Epic, Amazon, and custom games without login.FavoritesManager: exposes aStateFlow, loads off the main thread, uses atomic updates, and replays early edits so async loads never overwrite them.FavoritesUtils: pure helpers (add/remove/filter/count/countPresent) with unit tests.favorite_app_ids(no migration).SnackbarManager(now supports action + callback).Bug Fixes
FavoritesUtils.toggle.Written for commit c052139. Summary will update on new commits.
Summary by CodeRabbit