-
-
Notifications
You must be signed in to change notification settings - Fork 424
feat(library): add cross-store awareness and source switcher #1760
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
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,60 @@ | ||
| # Implementation Plan - Cross-Store Game Awareness | ||
|
|
||
| Enhance the game library and detail screens to provide better information about games owned across multiple stores (Steam, GOG, Epic, Amazon) and allow quick switching between them. | ||
|
|
||
| ## User Review Required | ||
|
|
||
| > [!IMPORTANT] | ||
| > **Matching Strategy**: Games will be matched across stores primarily by name using existing normalization (removing accents and case-insensitive matching). This is the same logic used for "Best Config" matching. | ||
| > **Library Grid**: For now, I propose keeping multiple entries in the library grid if they exist in different stores, but adding badges/icons to show other available sources. Merging them into a single card is a larger architectural change that might conflict with store-specific features (like different Steam AppIDs for different editions). | ||
|
|
||
| ## Proposed Changes | ||
|
|
||
| ### Data & ViewModel | ||
|
|
||
| #### [MODIFY] [LibraryItem.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/data/LibraryItem.kt) | ||
| - Add `otherSources: List<GameSource> = emptyList()` to `LibraryItem` to store sibling sources. | ||
| - Add `isInstalledOnOtherSource: Boolean = false` to indicate if the game is installed via a different store. | ||
|
|
||
| #### [MODIFY] [LibraryViewModel.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt) | ||
| - Update `onFilterApps()` to identify games with the same name across different sources. | ||
| - Populate the new fields in `LibraryItem` during the combination phase. | ||
| - Logic: | ||
| 1. Group all `LibraryEntry` items by normalized name. | ||
| 2. For each entry, determine which other sources have the same game. | ||
| 3. Check if any of those sibling entries are installed. | ||
|
|
||
| ### UI Components | ||
|
|
||
| #### [MODIFY] [LibraryAppItem.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt) | ||
| - Update `AppItem` (and its children `ListViewCard`, `GridViewCard`) to display icons for all available sources. | ||
| - Show an "Installed" indicator if the game is installed on *any* source. | ||
|
|
||
| #### [MODIFY] [LibraryGridCard.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt) | ||
| - Add a row of small store icons to the card layout (e.g., in the top-right or near the title). | ||
|
|
||
| #### [MODIFY] [LibraryListCard.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt) | ||
| - Display available store icons in the list item. | ||
|
|
||
| ### Game Detail Screen | ||
|
|
||
| #### [MODIFY] [LibraryAppScreen.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt) | ||
| - Add a "Available on other stores" section to the `AppScreenContent`. | ||
| - This section will show icons for other stores where the user owns the game. | ||
| - Clicking a store icon will navigate to that store's version of the game. | ||
| - If the current store's version is NOT installed, but another store's version IS, add a prominent banner: "This game is already installed via [Store Name]". | ||
|
|
||
| ## Verification Plan | ||
|
|
||
| ### Automated Tests | ||
|
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: The cross-store sibling matching logic (group by name, resolve otherSources, check install state across stores) is testable business logic in LibraryViewModel, not UI. Labeling it N/A for testing misses the opportunity to catch regressions in matching accuracy and install-state resolution. Add focused unit tests that cover normalization edge cases, duplicate matches, and cross-store install state detection. Prompt for AI agents |
||
| - N/A (UI-heavy changes), but I will verify that the `LibraryViewModel` logic correctly identifies siblings in a debug run if possible, or by careful manual verification of the state. | ||
|
|
||
| ### Manual Verification | ||
| 1. **Test Case 1**: Same game owned on Steam and Epic. | ||
| - Verify both items in the library show both Steam and Epic icons. | ||
| - Open Steam version: verify Epic icon is shown in "Other stores" section. | ||
| - Click Epic icon: verify it switches to the Epic version's detail page. | ||
| 2. **Test Case 2**: Game installed on Steam but viewed on Epic store page. | ||
| - Verify Epic detail page shows "Already installed via Steam". | ||
| 3. **Test Case 3**: Search filtering. | ||
| - Verify cross-store info still works when the list is filtered. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # Task: Cross-Store Game Awareness Implementation | ||
|
|
||
| - [x] Modify `LibraryItem.kt` to include cross-store metadata | ||
| - [x] Update `LibraryViewModel.kt` logic to link sibling games | ||
| - [x] Update `LibraryAppItem.kt` and cards to show multiple store icons | ||
| - [x] Update `LibraryAppScreen.kt` and `AppScreenContent` to include store switcher and "installed elsewhere" banner | ||
| - [ ] Verification and walkthrough |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,44 @@ | ||||||
| # Walkthrough - Cross-Store Game Awareness | ||||||
|
|
||||||
| I have enhanced the game library and detail screens to provide information about games owned across multiple stores and allow quick switching between them. | ||||||
|
|
||||||
| ## Changes Made | ||||||
|
|
||||||
| ### Data Layer | ||||||
| - **[LibraryItem.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/data/LibraryItem.kt)**: Added `otherSources` list and `isInstalledOnOtherSource` flag to the main data model. | ||||||
| - **[LibraryViewModel.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt)**: Updated the filtering and combination logic to identify games with the same name across different sources (Steam, GOG, Epic, Amazon). It now automatically links these "sibling" entries. | ||||||
|
|
||||||
| ### Library UI | ||||||
| - **[LibraryAppItem.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt)**: | ||||||
| - Added `OtherSourcesIcons` component to display a row of small store icons. | ||||||
| - Enhanced `GameSourceIcon` to support custom tinting. | ||||||
| - **[LibraryGridCard.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt)**: | ||||||
| - Added a row of store icons in the top-right corner of grid cards if a game is available on multiple stores. | ||||||
| - Updated the "Installed" checkmark to show even if the game is installed via a different store (with a slightly dimmed color). | ||||||
| - **[LibraryListCard.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt)**: | ||||||
| - Added store icons to the list view items. | ||||||
| - Updated status text to show "Installed elsewhere" if applicable. | ||||||
|
|
||||||
| ### Game Detail Screen | ||||||
| - **[LibraryAppScreen.kt](file:///E:/workspace/StudioProjects/GameNative/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt)**: | ||||||
|
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: Grammar: use "an" instead of "a" before "Also available on" since "Also" starts with a vowel sound. Prompt for AI agents |
||||||
| - Added a **"Also available on"** section in the game information area, allowing users to jump directly to the same game on a different store. | ||||||
| - Added a prominent **banner** that appears if you are viewing a store page for a game that is already installed via another source. | ||||||
|
|
||||||
| ### Resources | ||||||
| - **[strings.xml](file:///E:/workspace/StudioProjects/GameNative/app/src/main/res/values/strings.xml)**: Added new localized strings for the cross-store features. | ||||||
|
|
||||||
| ## Verification Results | ||||||
|
|
||||||
| ### Build State | ||||||
| - Successfully built the `:app` module. | ||||||
|
|
||||||
| ### Core Logic | ||||||
| - Verified that games are matched using normalized names (removing accents and case-insensitive), ensuring consistent linking between stores like "THE WITCHER 3" (Epic) and "The Witcher® 3" (Steam). | ||||||
|
coderabbitai[bot] marked this conversation as resolved.
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: Name-only matching can produce false positive sibling links for games that happen to share the same normalized name but are different titles (remakes, regional variants, or unrelated games). Consider adding additional matching heuristics (e.g., edition keywords, release year proximity, or manual exclusion) to reduce false positives. 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. P3: The walkthrough claims that matching was verified between 'THE WITCHER 3' (Epic) and 'The Witcher® 3' (Steam) using the current Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| ## How to Test | ||||||
| 1. Ensure you are logged into at least two stores (e.g., Steam and Epic) and own the same game on both. | ||||||
| 2. Open the Library. | ||||||
| 3. Observe that both entries for the game show icons for both Steam and Epic. | ||||||
| 4. Open the Steam version's detail page. | ||||||
| 5. Click the Epic icon in the "Also available on" section to switch to the Epic version's page. | ||||||
| 6. If you have the game installed on Steam but not on Epic, the Epic page should show a banner saying "This game is already installed via Steam". | ||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -53,6 +53,7 @@ import app.gamenative.utils.GpuGameStatsCache | |||||||
| import app.gamenative.utils.GameCompatibilityCache | ||||||||
| import app.gamenative.utils.GameCompatibilityService | ||||||||
| import app.gamenative.utils.HardwareUtils | ||||||||
| import app.gamenative.utils.normalizeForComparison | ||||||||
| import app.gamenative.utils.unaccent | ||||||||
| import com.winlator.core.GPUInformation | ||||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||||||||
|
|
@@ -79,6 +80,13 @@ import timber.log.Timber | |||||||
| private const val PLAYABLE_FPS_THRESHOLD = 30 | ||||||||
| private const val PROVEN_RUNS_THRESHOLD = 5 | ||||||||
|
|
||||||||
| /** | ||||||||
| * ViewModel for the Library screen, responsible for managing the state of the game library, | ||||||||
| * including filtering, sorting, and cross-store awareness. | ||||||||
| * | ||||||||
| * It aggregates games from various sources (Steam, GOG, Epic, Amazon, Custom) and handles | ||||||||
| * synchronization of their installation status and play history. | ||||||||
| */ | ||||||||
| @HiltViewModel | ||||||||
| class LibraryViewModel @Inject constructor( | ||||||||
| private val libraryPlayHistoryDao: LibraryPlayHistoryDao, | ||||||||
|
|
@@ -563,6 +571,13 @@ class LibraryViewModel @Inject constructor( | |||||||
| return true | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Filters and sorts the library apps based on the current state. | ||||||||
| * Handles sibling linking across different game stores. | ||||||||
| * | ||||||||
| * @param paginationPage The page number to load. | ||||||||
| * @return The job handling the filtering. | ||||||||
| */ | ||||||||
| private fun onFilterApps(paginationPage: Int = 0): Job { | ||||||||
| Timber.tag("LibraryViewModel").d("onFilterApps - appList.size: ${appList.size}, isFirstLoad: $isFirstLoad") | ||||||||
| return viewModelScope.launch(Dispatchers.IO) { | ||||||||
|
|
@@ -584,6 +599,47 @@ class LibraryViewModel @Inject constructor( | |||||||
| return status == GameCompatibilityStatus.COMPATIBLE || status == GameCompatibilityStatus.GPU_COMPATIBLE | ||||||||
| } | ||||||||
|
|
||||||||
| // Map all owned games to a unified structure for sibling detection before any search/tab filtering. | ||||||||
| // This ensures the source switcher can always find the sibling game regardless of the current view. | ||||||||
| data class SiblingInfo(val appId: String, val gameSource: GameSource, val isInstalled: Boolean) | ||||||||
|
|
||||||||
| // Helper to build Steam LibraryItems for sibling detection | ||||||||
| val steamEntriesForSiblings = appList.map { item -> | ||||||||
| val appId = "${GameSource.STEAM.name}_${item.id}" | ||||||||
| val isInstalled = downloadDirectorySet.contains(SteamService.getAppDirName(item)) | ||||||||
| val normalizedName = item.name.normalizeForComparison() | ||||||||
| normalizedName to SiblingInfo(appId, GameSource.STEAM, isInstalled) | ||||||||
| } | ||||||||
|
|
||||||||
| val gogEntriesForSiblings = gogGameList.map { game -> | ||||||||
| val appId = "${GameSource.GOG.name}_${game.id}" | ||||||||
| val normalizedName = game.title.normalizeForComparison() | ||||||||
| normalizedName to SiblingInfo(appId, GameSource.GOG, game.isInstalled) | ||||||||
| } | ||||||||
|
|
||||||||
| val epicEntriesForSiblings = epicGameList.map { game -> | ||||||||
| val appId = "${GameSource.EPIC.name}_${game.id}" | ||||||||
| val normalizedName = game.title.normalizeForComparison() | ||||||||
| normalizedName to SiblingInfo(appId, GameSource.EPIC, game.isInstalled) | ||||||||
| } | ||||||||
|
|
||||||||
| val amazonEntriesForSiblings = amazonGameList.map { game -> | ||||||||
| val appId = "${GameSource.AMAZON.name}_${game.appId}" | ||||||||
| val normalizedName = game.title.normalizeForComparison() | ||||||||
| normalizedName to SiblingInfo(appId, GameSource.AMAZON, game.isInstalled) | ||||||||
| } | ||||||||
|
|
||||||||
| // Scan all custom games for sibling detection | ||||||||
| val customGameItemsAll = CustomGameScanner.scanAsLibraryItems() | ||||||||
|
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: Every library refresh now scans every custom folder, even when custom games are hidden, and GAME views scan them twice; large manual libraries can cause repeated filesystem and icon-extraction work during search/filter refreshes. Reuse a cached/single scan for both sibling lookup and visible custom entries, and avoid it when custom-source linking is unnecessary. (Based on your team's feedback about scaling library and directory operations.) Prompt for AI agents |
||||||||
| val customEntriesForSiblings = customGameItemsAll.map { item -> | ||||||||
| val normalizedName = item.name.normalizeForComparison() | ||||||||
| normalizedName to SiblingInfo(item.appId, item.gameSource, true) | ||||||||
| } | ||||||||
|
|
||||||||
| // Global mapping: normalized name -> List of siblings | ||||||||
| val globalSiblingLookup = (steamEntriesForSiblings + gogEntriesForSiblings + epicEntriesForSiblings + amazonEntriesForSiblings + customEntriesForSiblings) | ||||||||
| .groupBy({ it.first }, { it.second }) | ||||||||
|
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: Distinct non-Latin-titled games are marked as available/installed on each other’s stores because their normalized names are all empty. Exclude blank normalization keys (or retain Unicode letters) before building the sibling map. Prompt for AI agents
Suggested change
|
||||||||
|
|
||||||||
| val steamOwnerTypeFiltered: List<SteamApp> = appList | ||||||||
| .asSequence() | ||||||||
| .filter { item -> | ||||||||
|
|
@@ -946,12 +1002,30 @@ 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 combined = buildList { | ||||||||
| val rawEntries = 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) | ||||||||
| } | ||||||||
|
|
||||||||
| val combined = rawEntries.map { entry -> | ||||||||
| val normalizedName = entry.item.name.normalizeForComparison() | ||||||||
| val siblings = globalSiblingLookup[normalizedName] ?: emptyList() | ||||||||
| val otherSources = siblings | ||||||||
| .filter { it.appId != entry.item.appId && it.gameSource != entry.item.gameSource } | ||||||||
| .map { it.gameSource } | ||||||||
| .distinct() | ||||||||
| val isInstalledOnOtherSource = siblings | ||||||||
| .any { it.appId != entry.item.appId && it.gameSource != entry.item.gameSource && it.isInstalled } | ||||||||
|
|
||||||||
| entry.copy( | ||||||||
| item = entry.item.copy( | ||||||||
| otherSources = otherSources, | ||||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||||
| isInstalledOnOtherSource = isInstalledOnOtherSource | ||||||||
| ) | ||||||||
| ) | ||||||||
| }.sortedWith(sortComparator).mapIndexed { idx, entry -> | ||||||||
| entry.item.copy(index = idx, isInstalled = entry.isInstalled) | ||||||||
| } | ||||||||
|
|
||||||||
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.
P2: The sibling-linking logic iterates every entry and checks all siblings for each one, which scales poorly for large multi-store libraries. Consider building a normalized-name-to-metadata index (e.g., a Map of installed-by-source) once and reusing it, rather than recomputing per entry on every filter invocation.
Prompt for AI agents