diff --git a/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/implementation_plan.artifact.md b/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/implementation_plan.artifact.md new file mode 100644 index 0000000000..3d8fc08a3d --- /dev/null +++ b/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/implementation_plan.artifact.md @@ -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 = 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 +- 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. diff --git a/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/task.artifact.md b/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/task.artifact.md new file mode 100644 index 0000000000..3547e3e0ef --- /dev/null +++ b/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/task.artifact.md @@ -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 diff --git a/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/walkthrough.artifact.md b/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/walkthrough.artifact.md new file mode 100644 index 0000000000..645c55131a --- /dev/null +++ b/.artifacts/33d120e5-dd12-4370-9d8f-04c5ef636724/walkthrough.artifact.md @@ -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)**: + - 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). + +## 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". diff --git a/app/src/main/java/app/gamenative/data/LibraryItem.kt b/app/src/main/java/app/gamenative/data/LibraryItem.kt index a4be2be022..3e07657b93 100644 --- a/app/src/main/java/app/gamenative/data/LibraryItem.kt +++ b/app/src/main/java/app/gamenative/data/LibraryItem.kt @@ -46,6 +46,8 @@ data class LibraryItem( val recStoreCard: Boolean = false, val recSource: String = "", val isFeatured: Boolean = false, + val otherSources: List = emptyList(), + val isInstalledOnOtherSource: Boolean = false, ) { val clientIconUrl: String get() = when (gameSource) { diff --git a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt index 884332a57d..34554d2844 100644 --- a/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/LibraryViewModel.kt @@ -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() + 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 }) + val steamOwnerTypeFiltered: List = 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, + isInstalledOnOtherSource = isInstalledOnOtherSource + ) + ) }.sortedWith(sortComparator).mapIndexed { idx, entry -> entry.item.copy(index = idx, isInstalled = entry.isInstalled) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 55b94093b1..08abacadca 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete @@ -107,6 +108,7 @@ import androidx.compose.ui.unit.dp import app.gamenative.NetworkMonitor import app.gamenative.PrefManager import app.gamenative.R +import app.gamenative.data.GameSource import app.gamenative.data.LibraryItem import app.gamenative.service.SteamService import app.gamenative.ui.component.GamepadAction @@ -514,6 +516,7 @@ fun AppScreen( onTestGraphics: () -> Unit, onPlayWithDiagnostics: () -> Unit, onBack: () -> Unit, + onSourceClick: (GameSource) -> Unit = {}, ) { // Get the appropriate screen model based on game source val screenModel = remember(libraryItem.gameSource) { @@ -533,6 +536,7 @@ fun AppScreen( onTestGraphics = onTestGraphics, onPlayWithDiagnostics = onPlayWithDiagnostics, onBack = onBack, + onSourceClick = onSourceClick, ) } @@ -552,6 +556,10 @@ private fun formatBytes(bytes: Long): String { } } +/** + * Main content for the App Detail screen. + * This layout is shared across all game sources (Steam, GOG, Epic, Amazon, Custom). + */ @Composable internal fun AppScreenContent( modifier: Modifier = Modifier, @@ -571,6 +579,9 @@ internal fun AppScreenContent( onBack: () -> Unit = {}, optionsMenu: List, dialogOpen: Boolean = false, + otherSources: List = emptyList(), + isInstalledOnOtherSource: Boolean = false, + onSourceClick: (GameSource) -> Unit = {}, ) { val context = LocalContext.current // reactive — recomposes when network state changes @@ -1056,6 +1067,35 @@ internal fun AppScreenContent( Spacer(modifier = Modifier.height(16.dp)) } + // Installed elsewhere banner + if (!isInstalled && isInstalledOnOtherSource) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.library_already_installed_on, stringResource(R.string.library_other_stores)), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Game information section Text( text = stringResource(R.string.game_information), @@ -1173,6 +1213,49 @@ internal fun AppScreenContent( } } + if (otherSources.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.library_available_on), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + otherSources.forEach { source -> + Surface( + onClick = { onSourceClick(source) }, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + app.gamenative.ui.screen.library.components.GameSourceIcon(gameSource = source, iconSize = 16) + Text( + text = when (source) { + GameSource.STEAM -> stringResource(R.string.tab_steam) + GameSource.GOG -> stringResource(R.string.tab_gog) + GameSource.EPIC -> stringResource(R.string.tab_epic) + GameSource.AMAZON -> stringResource(R.string.tab_amazon) + GameSource.CUSTOM_GAME -> stringResource(R.string.tab_local) + }, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt index 4ba77b3842..925be3fba6 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt @@ -112,6 +112,7 @@ import app.gamenative.service.gog.GOGService import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.PlatformOAuthHandlers import app.gamenative.utils.SteamUtils +import app.gamenative.utils.unaccent import kotlinx.coroutines.launch import android.os.SystemClock @@ -1104,6 +1105,19 @@ private fun LibraryScreenContent( onPlayWithDiagnostics(libraryItem.appId) } }, + onSourceClick = { targetSource -> + val normalizedName = selectedLibraryItem?.name?.unaccent()?.lowercase()?.trim() + // Search in ALL games, not just the paged list if possible. + // For now, we search in the current paged list which is likely to have sibling if they were linked. + val targetItem = state.appInfoList.find { + it.gameSource == targetSource && + it.name.unaccent().lowercase().trim() == normalizedName + } + if (targetItem != null) { + selectedAppId = targetItem.appId + selectedLibraryItem = targetItem + } + }, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 03d6f40f59..9eebeda7e6 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -1001,6 +1001,7 @@ abstract class BaseAppScreen { onTestGraphics: () -> Unit, onPlayWithDiagnostics: () -> Unit, onBack: () -> Unit, + onSourceClick: (GameSource) -> Unit = {}, ) { val context = LocalContext.current val displayInfoBase = getGameDisplayInfo(context, libraryItem) @@ -1360,6 +1361,9 @@ abstract class BaseAppScreen { onBack = onBack, optionsMenu = optionsMenu, dialogOpen = showConfigDialog || manageModsRequested, + otherSources = libraryItem.otherSources, + isInstalledOnOtherSource = libraryItem.isInstalledOnOtherSource, + onSourceClick = onSourceClick, ) if (showReadiness && launchActivity != null) { diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt index 1a26ce97f1..cbf952c38c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt @@ -10,6 +10,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.graphics.Color import app.gamenative.R import androidx.compose.ui.draw.alpha import androidx.compose.foundation.lazy.LazyColumn @@ -158,24 +162,46 @@ fun GameSourceIcon( modifier: Modifier = Modifier, iconSize: Int = 12, alignmentBoxSize: Int = 20, + tint: Color = Color.Unspecified, ) { + val alpha = if (tint == Color.Unspecified) 0.7f else 1f Box( modifier = modifier.size(alignmentBoxSize.dp), contentAlignment = androidx.compose.ui.Alignment.Center, ) { when (gameSource) { - GameSource.STEAM -> Icon(imageVector = Icons.Filled.Steam, contentDescription = "Steam", modifier = Modifier.size(iconSize.dp).alpha(0.7f)) - GameSource.CUSTOM_GAME -> Icon(imageVector = Icons.Filled.Folder, contentDescription = "Custom Game", modifier = Modifier.size(iconSize.dp).alpha(0.7f)) - GameSource.GOG -> Icon(painter = painterResource(R.drawable.ic_gog), contentDescription = "Gog", modifier = Modifier.size(iconSize.dp).alpha(0.7f)) - GameSource.EPIC -> Icon(painter = painterResource(R.drawable.ic_epic), contentDescription = "Epic", modifier = Modifier.size(iconSize.dp).alpha(0.7f)) - GameSource.AMAZON -> Icon(imageVector = Icons.Filled.Amazon, contentDescription = "Amazon", modifier = Modifier.size(iconSize.dp).alpha(0.7f)) + GameSource.STEAM -> Icon(imageVector = Icons.Filled.Steam, contentDescription = "Steam", modifier = Modifier.size(iconSize.dp).alpha(alpha), tint = if (tint == Color.Unspecified) Color.Unspecified else tint) + GameSource.CUSTOM_GAME -> Icon(imageVector = Icons.Filled.Folder, contentDescription = "Custom Game", modifier = Modifier.size(iconSize.dp).alpha(alpha), tint = if (tint == Color.Unspecified) Color.Unspecified else tint) + GameSource.GOG -> Icon(painter = painterResource(R.drawable.ic_gog), contentDescription = "Gog", modifier = Modifier.size(iconSize.dp).alpha(alpha), tint = if (tint == Color.Unspecified) Color.Unspecified else tint) + GameSource.EPIC -> Icon(painter = painterResource(R.drawable.ic_epic), contentDescription = "Epic", modifier = Modifier.size(iconSize.dp).alpha(alpha), tint = if (tint == Color.Unspecified) Color.Unspecified else tint) + GameSource.AMAZON -> Icon(imageVector = Icons.Filled.Amazon, contentDescription = "Amazon", modifier = Modifier.size(iconSize.dp).alpha(alpha), tint = if (tint == Color.Unspecified) Color.Unspecified else tint) } } } -/*********** - * PREVIEW * - ***********/ +@Composable +fun OtherSourcesIcons( + sources: List, + modifier: Modifier = Modifier, + iconSize: Int = 12, + alignmentBoxSize: Int = 18, +) { + if (sources.isEmpty()) return + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy((-4).dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + sources.forEach { source -> + GameSourceIcon( + gameSource = source, + iconSize = iconSize, + alignmentBoxSize = alignmentBoxSize, + modifier = Modifier.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), CircleShape) + ) + } + } +} @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt index 8994f938f6..25301c50df 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryDetailPane.kt @@ -31,6 +31,7 @@ internal fun LibraryDetailPane( onTestGraphics: () -> Unit, onPlayWithDiagnostics: () -> Unit, onBack: () -> Unit, + onSourceClick: (GameSource) -> Unit = {}, ) { Surface { if (libraryItem == null) { @@ -102,6 +103,7 @@ internal fun LibraryDetailPane( onTestGraphics = onTestGraphics, onPlayWithDiagnostics = onPlayWithDiagnostics, onBack = onBack, + onSourceClick = onSourceClick, ) } } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt index 6ab394aca6..7bf1d03e0f 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryGridCard.kt @@ -360,13 +360,22 @@ internal fun GridViewCard( ) } } else if (!appInfo.isRecommended) { - GameSourceIcon( - gameSource = appInfo.gameSource, + Row( modifier = Modifier .align(Alignment.TopEnd) .padding(top = topIconPadding, end = topIconPadding), - iconSize = if (isCapsule) 14 else 12, - ) + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OtherSourcesIcons( + sources = appInfo.otherSources, + iconSize = if (isCapsule) 14 else 12, + ) + GameSourceIcon( + gameSource = appInfo.gameSource, + iconSize = if (isCapsule) 14 else 12, + ) + } } } } @@ -503,16 +512,25 @@ private fun RecSimilarBadge(iconUrl: String?, extraCount: Int, modifier: Modifie /** * Status icons for grid view (installed, family share). + * Includes support for "installed elsewhere" status with appropriate accessibility labels. + * + * @param appInfo The library item information. */ @Composable private fun GridStatusIcons(appInfo: LibraryItem) { val isInstalled = appInfo.isInstalled + val isInstalledElsewhere = appInfo.isInstalledOnOtherSource Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (isInstalled) { + if (isInstalled || isInstalledElsewhere) { + val contentDescription = if (isInstalled) { + stringResource(R.string.library_installed) + } else { + stringResource(R.string.library_installed_elsewhere) + } Box( modifier = Modifier .size(20.dp) @@ -522,8 +540,8 @@ private fun GridStatusIcons(appInfo: LibraryItem) { ) { Icon( Icons.Filled.Check, - contentDescription = stringResource(R.string.library_installed), - tint = PluviaTheme.colors.statusInstalled, + contentDescription = contentDescription, + tint = if (isInstalled) PluviaTheme.colors.statusInstalled else PluviaTheme.colors.statusInstalled.copy(alpha = 0.6f), modifier = Modifier.size(12.dp), ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt index 585e131bd2..8b42c097c3 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/components/LibraryListCard.kt @@ -160,6 +160,8 @@ internal fun ListViewCard( ) { InstallStatusBadge(appInfo = appInfo, isRefreshing = isRefreshing) + OtherSourcesIcons(sources = appInfo.otherSources) + // Family share indicator if (appInfo.isShared) { Row( @@ -205,6 +207,10 @@ internal fun ListViewCard( /** * Compact install status badge for list view. + * Displays the current status of the game (Ready, Installing, Installed, or Not Installed). + * + * @param appInfo The library item information. + * @param isRefreshing Whether the library is currently refreshing. */ @Composable private fun InstallStatusBadge( @@ -242,11 +248,13 @@ private fun InstallStatusBadge( } val (text, color) = when { - !isSteam -> stringResource(R.string.library_status_ready) to MaterialTheme.colorScheme.tertiary - isDownloading -> "${(downloadProgress * 100).toInt()}%" to MaterialTheme.colorScheme.primary - isInstalled -> stringResource(R.string.library_installed) to MaterialTheme.colorScheme.tertiary + isInstalled && isSteam -> stringResource(R.string.library_installed) to MaterialTheme.colorScheme.tertiary + + appInfo.isInstalledOnOtherSource -> stringResource(R.string.library_installed_elsewhere) to MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f) + + !isSteam -> stringResource(R.string.library_status_ready) to MaterialTheme.colorScheme.tertiary else -> stringResource(R.string.library_not_installed) to MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.6f, diff --git a/app/src/main/java/app/gamenative/utils/StringUtils.kt b/app/src/main/java/app/gamenative/utils/StringUtils.kt index 1affce95b1..8c0029d53b 100644 --- a/app/src/main/java/app/gamenative/utils/StringUtils.kt +++ b/app/src/main/java/app/gamenative/utils/StringUtils.kt @@ -6,20 +6,49 @@ import java.text.Normalizer private val REGEX_UNACCENT = "\\p{M}+".toRegex() private val REGEX_FILENAME_UNSAFE = Regex("[^a-zA-Z0-9_-]") +private val REGEX_NON_ALPHANUMERIC = Regex("[^a-zA-Z0-9]") +/** + * Normalizes a game title for comparison across different stores. + * Removes accents, converts to lowercase, strips non-alphanumeric characters, and trims. + * For example, "The Witcher® 3" and "the-witcher-3" will both normalize to "thewitcher3". + * + * @return The normalized string. + */ +fun CharSequence.normalizeForComparison(): String { + return this.unaccent() + .lowercase() + .replace(REGEX_NON_ALPHANUMERIC, "") + .trim() +} /** * Extension functions relating to [String] as the receiver type. */ +/** + * Gets the avatar URL for a Steam persona. + * + * @return The avatar URL or a default missing avatar URL. + */ fun String.getAvatarURL(): String = this.ifEmpty { null } ?.takeIf { str -> str.isNotEmpty() && !str.all { it == '0' } } ?.let { "${Constants.Persona.AVATAR_BASE_URL}${it.substring(0, 2)}/${it}_full.jpg" } ?: Constants.Persona.MISSING_AVATAR_URL +/** + * Converts a string containing HTML entities to a plain string. + * + * @return The decoded string. + */ fun String.fromHtml(): String = Html.fromHtml(this, Html.FROM_HTML_MODE_LEGACY).toString() +/** + * Removes accents and diacritics from a string using NFKD normalization. + * + * @return The unaccented string. + */ fun CharSequence.unaccent(): String { val temp = Normalizer.normalize(this, Normalizer.Form.NFKD) return REGEX_UNACCENT.replace(temp, "") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dfd5518d88..3babf02462 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -97,6 +97,10 @@ Cancel Delete Untitled + Installed elsewhere + Also available on + Other stores + This game is already installed via %s Confirm Deletion Library Downloads & Storage diff --git a/app/src/test/java/app/gamenative/utils/StringUtilsTest.kt b/app/src/test/java/app/gamenative/utils/StringUtilsTest.kt index b5d4f25c2b..d082bdd4f3 100644 --- a/app/src/test/java/app/gamenative/utils/StringUtilsTest.kt +++ b/app/src/test/java/app/gamenative/utils/StringUtilsTest.kt @@ -10,4 +10,13 @@ class StringUtilsTest { assertEquals("Bo: Path of the Teal Lotus", "Bō: Path of the Teal Lotus".unaccent()) assertEquals("fiaAaAaAaAaAaAcCeEeEeEeEiIiIiIiInNoOoOoOoOoOuUuUuUuU", "fiáÁàÀâÂäÄãÃåÅçÇéÉèÈêÊëËíÍìÌîÎïÏñÑóÓòÒôÔöÖõÕúÚùÙûÛüÜ".unaccent()) } + + @Test + fun normalizeForComparisonTest() { + assertEquals("thewitcher3", "The Witcher® 3".normalizeForComparison()) + assertEquals("thewitcher3", "THE WITCHER 3".normalizeForComparison()) + assertEquals("thewitcher3", "the-witcher-3".normalizeForComparison()) + assertEquals("bopathoftheteallotus", "Bō: Path of the Teal Lotus".normalizeForComparison()) + assertEquals("cyberpunk2077", "Cyberpunk 2077™".normalizeForComparison()) + } }