From 7fb7aec4712c9664a8849990420782f807da4eeb Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:12:18 -0400 Subject: [PATCH 01/24] feat(nova): restructure the game detail window around its artwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window opened as a single scroll column stacking ten concerns: artwork, identity, launch controls, three inline sub-sheets, MangoHud status, two insight cards and the whole artwork studio. This makes the landing screen answer one question — do I want to play this — and gives everything else a destination. Overview goes full-bleed. It reuses NovaLibraryCinematicBackdrop, so hero-to-poster fallback, the crossfade and the theme scrims come with it rather than being rebuilt. Curated logo artwork becomes the title treatment at 200x64dp; the same asset was previously crushed into 56%x46% of a 136dp thumbnail inside a card inside a sheet. Below it, one reading order: who the game is, a hairline, what the primary action will do, then what you can do. The launch path loses its modals. onPrimaryLaunch branched three ways and only one of them launched; the other two raised a bottom sheet and a stock AlertDialog over the content. Neither needed porting, because the design already had the right home for each: a choice of where to run belongs in the Launch mode destination, and the preflight review is a statement about the profile, so it expands the status line in place and turns the alert's three buttons into the action lane. That removes the last raw AlertDialog in the detail, which the guards already forbade elsewhere. Artwork takes the whole window rather than a side panel. NovaArtworkStudio opens with a Row of weighted Columns and cannot fold into 60% of a landscape shell; Tune and Launch mode keep the panel. Both destinations lay their glass over an opaque ground. surfaces.panel is sheet glass, scaled again by the user's menu opacity, which is right over a dimmed sheet and wrong over a hero — without a ground beneath it the Overview read straight through Artwork. singleTop, because a fast double-press on a poster otherwise stacks two windows. Session-aware verbs and the playtime gauge are deliberately absent: both need data this window does not yet receive. --- .../papi/nova/ui/NovaGameDetailComposeTest.kt | 14 +- app/src/main/AndroidManifest.xml | 1 + .../papi/nova/ui/NovaGameDetailActivity.kt | 198 ++++----- .../com/papi/nova/ui/NovaGameDetailContent.kt | 267 +++++------ .../nova/ui/NovaGameDetailDestinations.kt | 283 ++++++++++++ .../papi/nova/ui/NovaGameDetailOverview.kt | 415 ++++++++++++++++++ .../ui/NovaArtworkStudioSourceGuardTest.kt | 28 +- .../nova/ui/NovaComposeSourceGuardTest.kt | 190 +++----- .../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 106 +++-- .../papi/nova/ui/NovaThemeResourcesTest.kt | 25 +- 10 files changed, 1097 insertions(+), 430 deletions(-) create mode 100644 app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt create mode 100644 app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt index a94d3052..990ebdd1 100644 --- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt +++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt @@ -7,6 +7,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.papi.nova.api.PolarisClientSettings +import com.papi.nova.api.PolarisApiClient import com.papi.nova.shared.polaris.model.PolarisGame import com.papi.nova.ui.compose.NovaComposeTheme import org.junit.Assert.assertTrue @@ -113,7 +114,18 @@ class NovaGameDetailComposeTest { iconAvailable = false, iconPresentationKey = "", iconLoader = {}, - coverLoader = {} + coverLoader = {}, + destination = NovaGameDetailDestination.OVERVIEW, + steamDecision = null, + reviewExpanded = false, + // No host is reachable under test; the backdrop simply draws nothing. + apiClient = PolarisApiClient( + InstrumentationRegistry.getInstrumentation().targetContext, + "", + ), + sourceLabel = "Steam", + onDestination = {}, + onSteamChoice = {}, ) } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cbd91979..d8df620b 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -294,6 +294,7 @@ diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index c07c712a..1f0ea738 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -176,6 +176,17 @@ class NovaGameDetailActivity : NovaActivity() { /** Artwork and MangoHUD edits made here; handed back so the library can merge them. */ private var updatedGame: PolarisGame? = null + private var destination by mutableStateOf(NovaGameDetailDestination.OVERVIEW) + + /** + * Set when Polaris reports desktop Steam active. It turns Launch mode into the + * three-way choice that used to be a bottom sheet raised over the content. + */ + private var steamDecision by mutableStateOf(null) + + /** The preflight review, expanded on the Overview rather than raised as an alert. */ + private var reviewExpanded by mutableStateOf(false) + override fun onCreate(savedInstanceState: Bundle?) { NovaThemeManager.applyTheme(this) super.onCreate(savedInstanceState) @@ -206,6 +217,7 @@ class NovaGameDetailActivity : NovaActivity() { this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { + if (dismissActiveDetailDestination()) return publishGameUpdate() finish() } @@ -215,6 +227,23 @@ class NovaGameDetailActivity : NovaActivity() { setUpDetail(game, apiClient) } + /** + * Unwinds one level: an expanded review collapses, a destination returns to the + * Overview, and only then does back leave for the library. + */ + private fun dismissActiveDetailDestination(): Boolean = when { + reviewExpanded -> { + reviewExpanded = false + true + } + destination != NovaGameDetailDestination.OVERVIEW -> { + destination = NovaGameDetailDestination.OVERVIEW + steamDecision = null + true + } + else -> false + } + /** Carries artwork or MangoHUD edits back even when the window closes without launching. */ private fun publishGameUpdate() { val game = updatedGame ?: return @@ -360,6 +389,30 @@ class NovaGameDetailActivity : NovaActivity() { loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display") } + fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) { + onLaunch?.invoke( + currentGame.copy(mangohud = mangoHudEnabled), + uiState.playUsesVirtualDisplay, + mirrorDesktop, + forcePrivateAfterSteamClose, + profilePreference, + optimizationState.rawOptimization + ) + finish() + } + + fun resetProfile() { + resetWorking = true + lifecycleScope.launch { + withContext(Dispatchers.IO) { + apiClient.clearOptimizerProfile(deviceName, currentGame.name) + } + optimizationState = NovaGameDetailOptimizationState() + loadOptimization(profilePreference) + resetWorking = false + } + } + setContentView( ComposeView(this).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) @@ -407,47 +460,39 @@ class NovaGameDetailActivity : NovaActivity() { coverContentDescription = getString(R.string.nova_a11y_game_cover), onPrimaryLaunch = { if (!uiState.playEnabled) return@NovaGameDetailContent - fun launchConfirmed(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) { - onLaunch?.invoke( - currentGame.copy(mangohud = mangoHudEnabled), - uiState.playUsesVirtualDisplay, - mirrorDesktop, - forcePrivateAfterSteamClose, - profilePreference, - optimizationState.rawOptimization - ) - finish() - } - val desktopSteamDecision = NovaDesktopSteamLaunchDecision.from( + val decision = NovaDesktopSteamLaunchDecision.from( uiState, optimizationState.rawOptimization ) - if (desktopSteamDecision.required) { - showDesktopSteamLaunchDecision( - decision = desktopSteamDecision, - onPrivateStream = { launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false) }, - onMirrorDesktop = { launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false) }, - onForcePrivateAfterSteamClose = { launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true) } - ) - } else if (optimizationState.reviewRequired) { - showPreflightReview( - optimizationState = optimizationState, - onLaunchConfirmed = { launchConfirmed(false) }, - onRetryHighFps = { retryHighFpsTrial() }, - onResetProfile = { - resetWorking = true - lifecycleScope.launch { - withContext(Dispatchers.IO) { - apiClient.clearOptimizerProfile(deviceName, currentGame.name) - } - optimizationState = NovaGameDetailOptimizationState() - loadOptimization(profilePreference) - resetWorking = false - } - } - ) - } else { - launchConfirmed(false) + when { + // A choice of where to run belongs in the destination named that, + // not in a sheet raised over the artwork. + decision.required -> { + steamDecision = decision + destination = NovaGameDetailDestination.LAUNCH_MODE + } + // The review is a statement about the profile, and the status + // line is where the profile lives, so it expands in place. + optimizationState.reviewRequired && !reviewExpanded -> { + reviewExpanded = true + } + else -> launchConfirmed(false) + } + }, + destination = destination, + steamDecision = steamDecision, + reviewExpanded = reviewExpanded, + apiClient = apiClient, + sourceLabel = currentGame.sourceLabel, + onDestination = { next -> destination = next }, + onSteamChoice = { choice -> + when (choice) { + NovaSteamLaunchChoice.PRIVATE_STREAM -> + launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = false) + NovaSteamLaunchChoice.MIRROR_DESKTOP -> + launchConfirmed(mirrorDesktop = true, forcePrivateAfterSteamClose = false) + NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE -> + launchConfirmed(mirrorDesktop = false, forcePrivateAfterSteamClose = true) } }, onLaunchOptions = { @@ -480,12 +525,10 @@ class NovaGameDetailActivity : NovaActivity() { usesVirtualDisplay = option.usesVirtualDisplay ) if (desktopSteamDecision.required) { - showDesktopSteamLaunchDecision( - decision = desktopSteamDecision, - onPrivateStream = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = false) }, - onMirrorDesktop = { launchSelected(mirrorDesktop = true, forcePrivateAfterSteamClose = false) }, - onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) } - ) + // Same destination the primary action routes to; picking an + // explicit option does not change where the choice belongs. + steamDecision = desktopSteamDecision + destination = NovaGameDetailDestination.LAUNCH_MODE } else { launchSelected(mirrorDesktop = false) } @@ -919,74 +962,7 @@ class NovaGameDetailActivity : NovaActivity() { ) } - private fun showPreflightReview( - optimizationState: NovaGameDetailOptimizationState, - onLaunchConfirmed: () -> Unit, - onRetryHighFps: () -> Unit, - onResetProfile: () -> Unit - ) { - val reason = optimizationState.reviewReason.ifBlank { "fps_override" } - val dialog = AlertDialog.Builder(this@NovaGameDetailActivity) - .setTitle(R.string.nova_library_preflight_review_title) - .setMessage(getString(R.string.nova_library_preflight_review_message, reason)) - .setPositiveButton(R.string.nova_library_preflight_launch) { _, _ -> onLaunchConfirmed() } - .setNeutralButton(R.string.nova_library_retry_high_fps) { _, _ -> onRetryHighFps() } - .setNegativeButton(R.string.nova_library_reset_game_profile) { _, _ -> onResetProfile() } - .create() - NovaSheetChrome.applyMenuOpacityToLegacyAlert(dialog) - dialog.show() - } - private fun showDesktopSteamLaunchDecision( - decision: NovaDesktopSteamLaunchDecision, - onPrivateStream: () -> Unit, - onMirrorDesktop: () -> Unit, - onForcePrivateAfterSteamClose: () -> Unit - ) { - val sheet = BottomSheetDialog(this@NovaGameDetailActivity) - val composeView = ComposeView(this@NovaGameDetailActivity).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) - background = NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity) - setContent { - NovaComposeTheme { - NovaDesktopSteamLaunchDecisionContent( - title = stringResource(R.string.nova_desktop_steam_title), - message = decision.reason.ifBlank { - stringResource(R.string.nova_desktop_steam_message) - }, - privateStreamLabel = stringResource(R.string.nova_desktop_steam_private_stream), - privateStreamUnavailableReason = decision.privateStreamUnavailableReason, - privateStreamEnabled = decision.privateStreamEnabled, - mirrorDesktopLabel = stringResource(R.string.nova_desktop_steam_mirror_desktop), - mirrorDesktopEnabled = decision.mirrorDesktopEnabled, - mirrorDesktopCaption = stringResource(R.string.nova_desktop_steam_mirror_caption), - forcePrivateLabel = decision.forcePrivateAfterSteamCloseLabel.ifBlank { - stringResource(R.string.nova_desktop_steam_force_private) - }, - forcePrivateEnabled = decision.forcePrivateAfterSteamCloseEnabled, - forcePrivateCaption = stringResource(R.string.nova_desktop_steam_force_private_caption), - cancelLabel = stringResource(R.string.nova_desktop_steam_cancel), - onPrivateStream = { - sheet.dismiss() - onPrivateStream() - }, - onMirrorDesktop = { - sheet.dismiss() - onMirrorDesktop() - }, - onForcePrivateAfterSteamClose = { - sheet.dismiss() - onForcePrivateAfterSteamClose() - }, - onCancel = { sheet.dismiss() } - ) - } - } - } - sheet.setContentView(composeView) - sheet.setOnShowListener { expandBottomSheet(sheet, composeView) } - sheet.show() - } private fun expandBottomSheet(bottomSheetDialog: BottomSheetDialog?, contentView: View) { val sheet = bottomSheetDialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 434f76cc..69c1a9bf 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -213,7 +214,7 @@ data class NovaSteamLaunchModeOptionsState( @Composable -fun NovaGameDetailContent( +internal fun NovaGameDetailContent( uiState: NovaGameDetailUiState, launchIntro: String, recommendedBadge: String, @@ -280,152 +281,154 @@ fun NovaGameDetailContent( iconPresentationKey: String, iconLoader: (ImageView) -> Unit, iconContentDescription: String = "", - coverLoader: (ImageView) -> Unit + coverLoader: (ImageView) -> Unit, + destination: NovaGameDetailDestination, + steamDecision: NovaDesktopSteamLaunchDecision?, + reviewExpanded: Boolean, + apiClient: PolarisApiClient, + sourceLabel: String, + onDestination: (NovaGameDetailDestination) -> Unit, + onSteamChoice: (NovaSteamLaunchChoice) -> Unit, ) { - val colors = LocalNovaComposeColors.current - val surfaces = LocalNovaLibrarySurfaces.current val verticalScroll = rememberScrollState() val playFocusRequester = remember { FocusRequester() } val detailsFocusRequester = remember { FocusRequester() } - Column( - modifier = modifier - .fillMaxWidth() - .fillMaxHeight() - .clip(RoundedCornerShape(topStart = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp, topEnd = NovaSheetChrome.SHEET_CORNER_RADIUS_DP.dp)) - .background(surfaces.panel) - ) { - NovaGameDetailScrollableContent( - scrollState = verticalScroll, - modifier = Modifier.weight(1f) - ) { - GameDetailsPanel( - uiState = uiState, - lastPlayedText = lastPlayedText, - coverContentDescription = coverContentDescription, - coverLoader = coverLoader, - artworkState = artworkState, - heroAvailable = heroAvailable, - heroPresentationKey = heroPresentationKey, - heroLoader = heroLoader, - heroContentDescription = heroContentDescription, - logoAvailable = logoAvailable, - logoPresentationKey = logoPresentationKey, - logoLoader = logoLoader, - logoContentDescription = logoContentDescription, - iconAvailable = iconAvailable, - iconPresentationKey = iconPresentationKey, - iconLoader = iconLoader, - iconContentDescription = iconContentDescription, - ) - - LaunchControlsPanel( - uiState = uiState, - launchIntro = launchIntro, - launchModeTitle = launchModeTitle, - recommendedBadge = recommendedBadge, - launchOptionsLabel = launchOptionsLabel, - profilePreferenceLabel = profilePreferenceLabel, - profileSummary = optimizationState.profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, - headlessModeLabel = headlessModeLabel, - virtualDisplayModeLabel = virtualDisplayModeLabel, - playFocusRequester = playFocusRequester, - detailsFocusRequester = detailsFocusRequester, - onLaunchOptions = onLaunchOptions, - onLaunchModeSelected = onLaunchModeSelected, - onProfilePreference = onProfilePreference, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile - ) + Box(modifier = modifier.fillMaxSize()) { + NovaGameDetailOverview( + uiState = uiState, + apiClient = apiClient, + playLabel = playLabel, + lastPlayedText = lastPlayedText, + sourceLabel = sourceLabel, + optimizationState = optimizationState, + reviewExpanded = reviewExpanded, + showLaunchModeAction = uiState.showLaunchOptionsButton, + logoAvailable = logoAvailable, + logoPresentationKey = logoPresentationKey, + logoLoader = logoLoader, + logoContentDescription = logoContentDescription, + playFocusRequester = playFocusRequester, + onPrimaryLaunch = onPrimaryLaunch, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + onDestination = onDestination, + ) - launchOptionsState?.let { - NovaLaunchOptionsSheet( - state = it, - onLaunch = onLaunchOptionSelected, - onDismiss = onDismissLaunchOptions - ) - } + when (destination) { + NovaGameDetailDestination.OVERVIEW -> Unit - profileOptionsState?.let { - NovaProfilePreferenceSheet( - state = it, - onSelected = onProfilePreferenceSelected, - onDismiss = onDismissProfileOptions - ) + NovaGameDetailDestination.LAUNCH_MODE -> NovaGameDetailPanel( + eyebrow = stringResource(R.string.nova_library_launch_mode_title), + headline = launchModeTitle, + readout = optimizationState.profileSummary?.selectedLine.orEmpty(), + scrollState = verticalScroll, + ) { + val decision = steamDecision + if (decision != null) { + NovaDesktopSteamLaunchDecisionRows( + decision = decision, + onChoice = onSteamChoice, + ) + } else { + LaunchControlsPanel( + uiState = uiState, + launchIntro = launchIntro, + launchModeTitle = launchModeTitle, + recommendedBadge = recommendedBadge, + launchOptionsLabel = launchOptionsLabel, + profilePreferenceLabel = profilePreferenceLabel, + profileSummary = optimizationState.profileSummary, + resetProfileLabel = resetProfileLabel, + resetProfileWorking = resetProfileWorking, + headlessModeLabel = headlessModeLabel, + virtualDisplayModeLabel = virtualDisplayModeLabel, + playFocusRequester = playFocusRequester, + detailsFocusRequester = detailsFocusRequester, + onLaunchOptions = onLaunchOptions, + onLaunchModeSelected = onLaunchModeSelected, + onProfilePreference = onProfilePreference, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile + ) + launchOptionsState?.let { + NovaLaunchOptionsSheet( + state = it, + onLaunch = onLaunchOptionSelected, + onDismiss = onDismissLaunchOptions + ) + } + } } - SteamLaunchModeCard( - visible = uiState.showSteamLaunchMode, - label = steamLaunchLabel, - modeLabel = steamLaunchModeLabel, - caption = steamLaunchCaption, - warning = uiState.steamLaunchWarning, - onClick = onSteamLaunchMode - ) - - steamLaunchOptionsState?.let { state -> - NovaSteamLaunchModeSheet( - state = state, - onSelected = onSteamLaunchModeSelected, - onDismiss = onDismissSteamLaunchModeOptions + NovaGameDetailDestination.TUNE -> NovaGameDetailPanel( + eyebrow = stringResource(R.string.nova_library_launch_options_secondary), + headline = optimizationState.profileSummary?.primaryLaunchLabel.orEmpty() + .ifBlank { profilePreferenceLabel }, + readout = optimizationState.profileSummary?.freshnessLine.orEmpty(), + scrollState = verticalScroll, + ) { + profileOptionsState?.let { + NovaProfilePreferenceSheet( + state = it, + onSelected = onProfilePreferenceSelected, + onDismiss = onDismissProfileOptions + ) + } + SteamLaunchModeCard( + visible = uiState.showSteamLaunchMode, + label = steamLaunchLabel, + modeLabel = steamLaunchModeLabel, + caption = steamLaunchCaption, + warning = uiState.steamLaunchWarning, + onClick = onSteamLaunchMode ) + steamLaunchOptionsState?.let { state -> + NovaSteamLaunchModeSheet( + state = state, + onSelected = onSteamLaunchModeSelected, + onDismiss = onDismissSteamLaunchModeOptions + ) + } + if (mangoHudEnabled) { + MangoHudPassiveStatus( + label = mangoHudStatusLabel, + caption = mangoHudStatusCaption, + warning = mangoHudWarning + ) + } + optimizationState.ai?.let { InsightCard(card = it) } + optimizationState.stability?.let { InsightCard(card = it) } } - if (mangoHudEnabled) { - MangoHudPassiveStatus( - label = mangoHudStatusLabel, - caption = mangoHudStatusCaption, - warning = mangoHudWarning + // The studio opens with a Row of weighted Columns, so it needs the window + // rather than the 438dp panel the other two destinations use. + NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen( + eyebrow = stringResource(R.string.nova_artwork_studio_title), + headline = uiState.game.name, + scrollState = verticalScroll, + ) { + NovaArtworkStudio( + state = artworkState, + initialQuery = uiState.game.name, + onRefresh = onRefreshArtwork, + onSearch = onSearchArtwork, + onIdentitySelected = onIdentitySelected, + onChangeIdentity = onIdentityChange, + onKindSelected = onKindSelected, + onChoiceSelected = onChoiceSelected, + onReset = onStudioAction, + onApply = onApplyArtwork, + onCancel = onStudioAction, + onClear = onClearArtwork, + onTransform = onLogoTransform, + candidatePreviewLoader = candidatePreviewLoader, + choicePreviewLoader = choicePreviewLoader, + currentArtworkPresentationKey = currentArtworkPresentationKey, + currentArtworkLoader = currentArtworkLoader, ) } - - optimizationState.ai?.let { - InsightCard(card = it) - } - - optimizationState.stability?.let { - InsightCard(card = it) - } - - NovaArtworkStudio( - state = artworkState, - initialQuery = uiState.game.name, - onRefresh = onRefreshArtwork, - onSearch = onSearchArtwork, - onIdentitySelected = onIdentitySelected, - onChangeIdentity = onIdentityChange, - onKindSelected = onKindSelected, - onChoiceSelected = onChoiceSelected, - onReset = onStudioAction, - onApply = onApplyArtwork, - onCancel = onStudioAction, - onClear = onClearArtwork, - onTransform = onLogoTransform, - candidatePreviewLoader = candidatePreviewLoader, - choicePreviewLoader = choicePreviewLoader, - currentArtworkPresentationKey = currentArtworkPresentationKey, - currentArtworkLoader = currentArtworkLoader, - ) - - NovaControllerHintBar( - hints = novaGameDetailControllerHints(), - compact = true, - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 12.dp) - ) } - - NovaGameDetailLaunchFooter( - playLabel = playLabel, - enabled = uiState.playEnabled, - onPrimaryLaunch = onPrimaryLaunch, - playFocusRequester = playFocusRequester, - detailsFocusRequester = detailsFocusRequester, - contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom) - ) } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt new file mode 100644 index 00000000..dd0b82c9 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -0,0 +1,283 @@ +package com.papi.nova.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.papi.nova.R +import com.papi.nova.ui.compose.LocalNovaComposeColors +import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces +import com.papi.nova.ui.compose.NovaControllerHint +import com.papi.nova.ui.compose.NovaControllerHintBar +import com.papi.nova.ui.compose.NovaFocusableCard + +/** The three ways a launch can go when Polaris reports desktop Steam active. */ +internal enum class NovaSteamLaunchChoice { + PRIVATE_STREAM, + MIRROR_DESKTOP, + CLOSE_STEAM_THEN_PRIVATE, +} + +/** + * A drill-in that sits beside the game rather than on top of it. The header is pinned and + * the body scrolls, so focus drives the scroll rather than the reverse. + */ +@Composable +internal fun NovaGameDetailPanel( + eyebrow: String, + headline: String, + readout: String, + scrollState: ScrollState, + content: @Composable () -> Unit, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + + Box(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) { + Column( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .fillMaxWidth(NOVA_DETAIL_PANEL_WIDTH_FRACTION) + .background(colors.window) + .background(surfaces.panel) + .windowInsetsPadding(WindowInsets.safeContent) + .padding(horizontal = NovaGameDetailInset, vertical = 20.dp) + .testTag("nova-game-detail-panel"), + ) { + NovaGameDetailDestinationHeader(eyebrow, headline, readout) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scrollState), + content = { content() }, + ) + NovaGameDetailDestinationHints() + } + } +} + +/** + * A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a + * Row of weighted Columns and cannot fold into a panel. + */ +@Composable +internal fun NovaGameDetailFullScreen( + eyebrow: String, + headline: String, + scrollState: ScrollState, + content: @Composable () -> Unit, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + Column( + modifier = Modifier + .fillMaxSize() + .background(colors.window) + .background(surfaces.panel) + .windowInsetsPadding(WindowInsets.safeContent) + .padding(horizontal = NovaGameDetailInset, vertical = 20.dp) + .testTag("nova-game-detail-fullscreen"), + ) { + NovaGameDetailDestinationHeader(eyebrow, headline, readout = "") + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scrollState), + content = { content() }, + ) + NovaGameDetailDestinationHints() + } +} + +/** Every destination says how to act and how to get back. */ +@Composable +private fun NovaGameDetailDestinationHints() { + NovaControllerHintBar( + hints = listOf( + NovaControllerHint( + key = stringResource(R.string.nova_controller_hint_a), + label = stringResource(R.string.nova_controller_hint_select), + ), + NovaControllerHint( + key = stringResource(R.string.nova_controller_hint_b), + label = stringResource(R.string.nova_controller_hint_back), + ), + ), + compact = true, + modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + ) +} + +@Composable +private fun NovaGameDetailDestinationHeader(eyebrow: String, headline: String, readout: String) { + val colors = LocalNovaComposeColors.current + Column(modifier = Modifier.padding(bottom = 14.dp)) { + Text( + text = eyebrow, + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.22.em, + ) + Text( + text = headline, + color = colors.textPrimary, + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 3.dp), + ) + if (readout.isNotBlank()) { + Text( + text = readout, + color = colors.textSecondary, + fontSize = 11.sp, + letterSpacing = 0.10.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 5.dp), + ) + } + } +} + +/** + * The desktop-Steam choice, as rows in Launch mode rather than a sheet over the artwork. + * A blocked option stays visible and inert: hiding it loses the reason it is blocked, + * which is the part worth reading. + */ +@Composable +internal fun NovaDesktopSteamLaunchDecisionRows( + decision: NovaDesktopSteamLaunchDecision, + onChoice: (NovaSteamLaunchChoice) -> Unit, +) { + val colors = LocalNovaComposeColors.current + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().testTag("nova-game-detail-steam-decision"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(colors.warning.copy(alpha = 0.13f)) + .border( + 1.dp, + colors.warning.copy(alpha = 0.46f), + RoundedCornerShape(NovaGameDetailCornerRadius), + ) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text( + text = stringResource(R.string.nova_desktop_steam_title), + color = colors.textPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = decision.reason.ifBlank { + stringResource(R.string.nova_desktop_steam_message) + }, + color = colors.textSecondary, + fontSize = 11.sp, + lineHeight = 15.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + + NovaSteamChoiceRow( + label = stringResource(R.string.nova_desktop_steam_private_stream), + caption = decision.privateStreamUnavailableReason, + enabled = decision.privateStreamEnabled, + onClick = { onChoice(NovaSteamLaunchChoice.PRIVATE_STREAM) }, + ) + if (decision.forcePrivateAfterSteamCloseEnabled) { + NovaSteamChoiceRow( + label = decision.forcePrivateAfterSteamCloseLabel.ifBlank { + stringResource(R.string.nova_desktop_steam_force_private) + }, + caption = stringResource(R.string.nova_desktop_steam_force_private_caption), + enabled = true, + onClick = { onChoice(NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE) }, + ) + } + NovaSteamChoiceRow( + label = stringResource(R.string.nova_desktop_steam_mirror_desktop), + caption = stringResource(R.string.nova_desktop_steam_mirror_caption), + enabled = decision.mirrorDesktopEnabled, + onClick = { onChoice(NovaSteamLaunchChoice.MIRROR_DESKTOP) }, + ) + } +} + +@Composable +private fun NovaSteamChoiceRow( + label: String, + caption: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val colors = LocalNovaComposeColors.current + NovaFocusableCard( + onClick = onClick, + enabled = enabled, + contentDescription = label, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + Text( + text = label, + color = if (enabled) colors.textPrimary else colors.textMuted, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + if (caption.isNotBlank()) { + Text( + text = caption, + color = colors.textMuted, + fontSize = 11.sp, + lineHeight = 14.sp, + modifier = Modifier.padding(top = 3.dp), + ) + } + } + } +} + +/** + * Wide enough for the insight cards, which carry a single-line profile badge that + * truncated at 53%, and still narrow enough to keep the game present beside it. + */ +private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt new file mode 100644 index 00000000..6e68c2f0 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -0,0 +1,415 @@ +package com.papi.nova.ui + +import android.widget.ImageView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.papi.nova.R +import com.papi.nova.api.PolarisApiClient +import com.papi.nova.shared.polaris.model.PolarisGame +import com.papi.nova.ui.compose.LocalNovaComposeColors +import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces +import com.papi.nova.ui.compose.NovaActionButton +import com.papi.nova.ui.compose.NovaControllerHint +import com.papi.nova.ui.compose.NovaControllerHintBar + +/** Where the detail window currently is. Back unwinds one level before leaving. */ +internal enum class NovaGameDetailDestination { OVERVIEW, LAUNCH_MODE, TUNE, ARTWORK } + +/** Content insets shared by the Overview and the destinations that sit beside it. */ +internal val NovaGameDetailInset = 28.dp +internal val NovaGameDetailFloor = 58.dp + +/** Every focusable control clears the accessible target floor. */ +internal val NovaGameDetailActionHeight = 48.dp + +/** Matches the library's surface radius; the sharp edge was a deliberate choice there. */ +internal val NovaGameDetailCornerRadius = 8.dp + +/** + * The landing screen of the detail window. + * + * The artwork is the subject and nothing sits on it in a card: the content block rests on + * the scrim floor in one reading order — who the game is, a hairline, what pressing the + * primary action will do, then what you can do. Layout is complete before the artwork + * arrives, so a slow or failed load changes the backdrop and moves nothing. + */ +@Composable +internal fun NovaGameDetailOverview( + uiState: NovaGameDetailUiState, + apiClient: PolarisApiClient, + playLabel: String, + lastPlayedText: String?, + sourceLabel: String, + optimizationState: NovaGameDetailOptimizationState, + reviewExpanded: Boolean, + showLaunchModeAction: Boolean, + logoAvailable: Boolean, + logoPresentationKey: String, + logoLoader: (ImageView) -> Unit, + logoContentDescription: String, + playFocusRequester: FocusRequester, + onPrimaryLaunch: () -> Unit, + onRetryHighFps: () -> Unit, + onResetProfile: () -> Unit, + onDestination: (NovaGameDetailDestination) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = LocalNovaComposeColors.current + val game = uiState.game + + Box(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) { + NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f) + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent) + .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, bottom = 10.dp), + ) { + NovaGameDetailTitle( + game = game, + logoAvailable = logoAvailable, + logoPresentationKey = logoPresentationKey, + logoLoader = logoLoader, + logoContentDescription = logoContentDescription, + ) + + Text( + text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game), + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.17.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 11.dp), + ) + + // The hairline divides names from numbers: identity above, machine state below. + Box( + modifier = Modifier + .padding(top = 11.dp) + .width(330.dp) + .height(1.dp) + .background( + Brush.horizontalGradient( + colorStops = arrayOf( + 0.0f to colors.accent, + 0.30f to colors.accent.copy(alpha = 0.44f), + 0.62f to colors.textMuted.copy(alpha = 0.16f), + 1.0f to Color.Transparent, + ), + ), + ), + ) + + NovaGameDetailStatusLine( + uiState = uiState, + optimizationState = optimizationState, + modifier = Modifier.padding(top = 11.dp), + ) + + if (reviewExpanded) { + LaunchProfileReviewNotice( + optimizationState = optimizationState, + modifier = Modifier.padding(top = 10.dp), + ) + } + + NovaGameDetailActions( + uiState = uiState, + optimizationState = optimizationState, + playLabel = playLabel, + reviewExpanded = reviewExpanded, + showLaunchModeAction = showLaunchModeAction, + playFocusRequester = playFocusRequester, + onPrimaryLaunch = onPrimaryLaunch, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + onDestination = onDestination, + modifier = Modifier.padding(top = 16.dp), + ) + + NovaControllerHintBar( + hints = novaGameDetailOverviewHints(), + compact = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + } + + } +} + +/** + * Curated logo artwork replaces the title outright when it is ready at first composition. + * A logo that arrives later is ignored: swapping a settled title for one is a visible jump, + * and the title is never wrong. + */ +@Composable +private fun NovaGameDetailTitle( + game: PolarisGame, + logoAvailable: Boolean, + logoPresentationKey: String, + logoLoader: (ImageView) -> Unit, + logoContentDescription: String, +) { + if (logoAvailable) { + key(logoPresentationKey) { + AndroidView( + factory = { context -> + ImageView(context).apply { + scaleType = ImageView.ScaleType.FIT_START + contentDescription = logoContentDescription + logoLoader(this) + } + }, + modifier = Modifier + .sizeIn(maxWidth = 200.dp, maxHeight = 64.dp) + .semantics { contentDescription = logoContentDescription } + .testTag("nova-game-detail-logo"), + ) + } + } else { + Text( + text = game.name, + color = LocalNovaComposeColors.current.textPrimary, + fontSize = 38.sp, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.03).em, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("nova-game-detail-title"), + ) + } +} + +/** + * What pressing the primary action will do, read as an instrument line rather than a chip: + * an indicator lamp, then mode and profile set with tabular figures. + */ +@Composable +private fun NovaGameDetailStatusLine( + uiState: NovaGameDetailUiState, + optimizationState: NovaGameDetailOptimizationState, + modifier: Modifier = Modifier, +) { + val colors = LocalNovaComposeColors.current + val summary = optimizationState.profileSummary + val limited = optimizationState.reviewRequired || + summary?.noticeTone == NovaLaunchProfileNoticeTone.WARNING + val lamp = when { + limited -> colors.warning + summary == null -> colors.textMuted + else -> colors.accent + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + modifier = modifier.testTag("nova-game-detail-status"), + ) { + Box(modifier = Modifier.size(7.dp).clip(RoundedCornerShape(percent = 50)).background(lamp)) + Text( + text = novaGameDetailStatusText(uiState, summary), + color = colors.textPrimary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.11.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * The action lane. Play holds first focus; the rail is gated so it never grows a node that + * leads nowhere. While a review is expanded the lane becomes the review's own choices — + * the three buttons of the alert this replaces. + */ +@Composable +private fun NovaGameDetailActions( + uiState: NovaGameDetailUiState, + optimizationState: NovaGameDetailOptimizationState, + playLabel: String, + reviewExpanded: Boolean, + showLaunchModeAction: Boolean, + playFocusRequester: FocusRequester, + onPrimaryLaunch: () -> Unit, + onRetryHighFps: () -> Unit, + onResetProfile: () -> Unit, + onDestination: (NovaGameDetailDestination) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + NovaActionButton( + text = playLabel, + onClick = onPrimaryLaunch, + enabled = uiState.playEnabled, + primary = true, + minHeight = NovaGameDetailActionHeight, + cornerRadius = NovaGameDetailCornerRadius, + fontSize = 15.sp, + contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp), + modifier = Modifier + .focusRequester(playFocusRequester) + .testTag("nova-game-detail-primary"), + ) + + if (reviewExpanded) { + if (optimizationState.profileSummary?.showRetryHighFps == true) { + NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_retry_high_fps), onRetryHighFps) + } + NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_reset_game_profile), onResetProfile) + } else { + if (showLaunchModeAction) { + NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_mode_title)) { + onDestination(NovaGameDetailDestination.LAUNCH_MODE) + } + } + NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_options_secondary)) { + onDestination(NovaGameDetailDestination.TUNE) + } + NovaGameDetailSecondaryAction(stringResource(R.string.nova_artwork_studio_title)) { + onDestination(NovaGameDetailDestination.ARTWORK) + } + } + } +} + +@Composable +private fun NovaGameDetailSecondaryAction(text: String, onClick: () -> Unit) { + val surfaces = LocalNovaLibrarySurfaces.current + NovaActionButton( + text = text, + onClick = onClick, + minHeight = NovaGameDetailActionHeight, + cornerRadius = NovaGameDetailCornerRadius, + fontSize = 13.sp, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(surfaces.control.copy(alpha = 1f)), + ) +} + +/** + * The alert this replaces stated the reason and offered launch, retry and reset. Expanded + * in place it keeps all four, without a dialog landing on the artwork. + */ +@Composable +private fun LaunchProfileReviewNotice( + optimizationState: NovaGameDetailOptimizationState, + modifier: Modifier = Modifier, +) { + val colors = LocalNovaComposeColors.current + val summary = optimizationState.profileSummary + val detail = listOf( + summary?.noticeDetail, + summary?.noticeRecommendation, + summary?.reasonLine, + ).firstOrNull { !it.isNullOrBlank() } + ?: stringResource( + R.string.nova_library_preflight_review_message, + optimizationState.reviewReason.ifBlank { "fps_override" }, + ) + + Column( + modifier = modifier + .sizeIn(maxWidth = 520.dp) + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(colors.warning.copy(alpha = 0.13f)) + .border(1.dp, colors.warning.copy(alpha = 0.46f), RoundedCornerShape(NovaGameDetailCornerRadius)) + .padding(horizontal = 12.dp, vertical = 10.dp) + .testTag("nova-game-detail-review"), + ) { + Text( + text = stringResource(R.string.nova_library_preflight_review_title), + color = colors.textPrimary, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = detail, + color = colors.textSecondary, + fontSize = 11.sp, + lineHeight = 15.sp, + modifier = Modifier.padding(top = 5.dp), + ) + } +} + +/** Source, when it was last played, and its primary genre — who the game is. */ +private fun novaGameDetailIdentityLine( + sourceLabel: String, + lastPlayedText: String?, + game: PolarisGame, +): String = listOf(sourceLabel, lastPlayedText, game.genres.firstOrNull()) + .filter { !it.isNullOrBlank() } + .joinToString(" · ") + +/** Mode, profile and freshness — what the primary action will do. */ +private fun novaGameDetailStatusText( + uiState: NovaGameDetailUiState, + summary: NovaLaunchProfileSummary?, +): String { + return listOf( + uiState.hostStreamDisplayModeLabel.takeIf { uiState.playUsesVirtualDisplay }, + summary?.selectedLine, + summary?.limitingLine?.takeIf { it.isNotBlank() } ?: summary?.freshnessLine, + ).filter { !it.isNullOrBlank() }.joinToString(" · ") +} + +/** + * B unwinds a level, X reaches Tune. A is not repeated here: the primary action already + * carries it, and this window exists to remove duplication. + */ +@Composable +private fun novaGameDetailOverviewHints(): List = listOf( + NovaControllerHint( + key = stringResource(R.string.nova_controller_hint_b), + label = stringResource(R.string.nova_controller_hint_close), + ), +) diff --git a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt index af2e2a7d..1b7fb68f 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaArtworkStudioSourceGuardTest.kt @@ -81,7 +81,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun detailSheetUsesArtworkStudioInsteadOfThePosterCentricFixMatchFlow() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt") val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt") @@ -120,7 +122,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun studioMutationsAreOwnedByRetainedViewModelAndUiCallbacksAreLifecycleGated() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt") val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt") @@ -189,7 +193,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun gameDetailsPreservesCachedHeroAvailabilityAndStablePresentationIdentity() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val contentCall = detail.section( "NovaGameDetailContent(", "\n loadOptimization(profilePreference)", @@ -244,7 +250,9 @@ class NovaArtworkStudioSourceGuardTest { fun applyIsOneExplicitAtomicMutationAndFailureRequiresRefetch() { val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt") val stateReducer = studio.section( "fun reduce(action: NovaArtworkStudioAction)", @@ -309,7 +317,9 @@ class NovaArtworkStudioSourceGuardTest { fun resetAndCancelDiscardDraftLocallyWithoutServerMutation() { val studio = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val resetButton = studio.section( "text = stringResource(R.string.nova_artwork_studio_reset)", "text = stringResource(R.string.nova_artwork_studio_apply)", @@ -334,7 +344,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun ordinaryDetailAndLibraryLoadingCannotStartChoiceOrProviderMutationTraffic() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt") val detailLoad = detail.section( "private fun loadArtworkState(", @@ -351,7 +363,9 @@ class NovaArtworkStudioSourceGuardTest { @Test fun studioRefreshUsesTheRetainedPerGameMutationCoordinator() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val library = readSource("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt") val updater = readSource("src/main/java/com/papi/nova/ui/NovaArtworkLibraryUpdater.kt") diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index 5bfae711..192cf7a3 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -1073,97 +1073,42 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailLaunchControlsPrioritizePrimaryPlayFocus() { val detail = readNovaGameDetail() - val launchControls = detail.section( - "private fun LaunchControls(", - "@Composable\nprivate fun LaunchProfileSummaryInline(" - ) - val launchFooter = detail.section( - "internal fun NovaGameDetailLaunchFooter(", - "@Composable\nprivate fun NovaDetailPanel(" - ) - val sheetContent = detail.section( - "fun NovaGameDetailContent(", - "@Composable\nprivate fun NovaGameDetailScrollableContent(" + val overview = detail.section( + "internal fun NovaGameDetailOverview(", + "private fun NovaGameDetailTitle(" ) - val scrollableContent = detail.section( - "private fun NovaGameDetailScrollableContent(", - "@Composable\nprivate fun novaGameDetailControllerHints(" + val actions = detail.section( + "private fun NovaGameDetailActions(", + "private fun NovaGameDetailSecondaryAction(" ) - val scrollBody = sheetContent.blockStartingAt("NovaGameDetailScrollableContent(") assertTrue( - "game detail should focus the pinned primary play action when the sheet opens", - sheetContent.contains("val playFocusRequester = remember { FocusRequester() }") && - launchFooter.contains("playFocusRequester.requestFocus()") && - launchFooter.contains(".focusRequester(playFocusRequester)") + "the primary action holds first focus, and nothing scrolls above it", + detail.contains("val playFocusRequester = remember { FocusRequester() }") && + actions.contains(".focusRequester(playFocusRequester)") && + actions.contains("primary = true") && + !overview.contains("verticalScroll") ) assertTrue( - "game detail should preserve an explicit controller focus route between pinned Play and More details", - sheetContent.contains("val detailsFocusRequester = remember { FocusRequester() }") && - launchControls.contains("detailsFocusRequester = detailsFocusRequester") && - launchControls.contains("playFocusRequester = playFocusRequester") && - launchFooter.contains(".focusProperties { up = detailsFocusRequester }") && - launchControls.contains(".focusProperties { down = playFocusRequester }") + "the action lane is one row, so a D-pad walk never leaves it", + actions.contains("Row(") && + actions.contains("horizontalArrangement = Arrangement.spacedBy(10.dp)") ) assertTrue( - "the pinned footer should keep Play as a full-width primary action above the safe-content bottom inset", - launchFooter.section("text = playLabel", "enabled = enabled") - .contains(".fillMaxWidth()\n .focusRequester(playFocusRequester)") && - launchFooter.contains(".windowInsetsPadding(contentInsets)") && - launchFooter.contains(".padding(start = 14.dp, end = 14.dp, top = 4.dp, bottom = 18.dp)") && - sheetContent.contains("contentInsets = WindowInsets.safeContent.only(WindowInsetsSides.Bottom)") + "every focusable action clears the accessible target floor", + detail.contains("internal val NovaGameDetailActionHeight = 48.dp") && + actions.contains("minHeight = NovaGameDetailActionHeight") ) assertTrue( - "the game-detail root must remain bounded while the detail body owns the remaining height and vertical scrolling", - sheetContent.contains(".fillMaxHeight()") && - sheetContent.contains("modifier = Modifier.weight(1f)") && - scrollableContent.contains(".verticalScroll(scrollState)") - ) - assertTrue( - "the pinned Play footer must be composed after and outside the scrollable detail lambda", - scrollBody.contains("GameDetailsPanel(") && - scrollBody.contains("LaunchControlsPanel(") && - scrollBody.contains("NovaControllerHintBar(") && - !scrollBody.contains("NovaGameDetailLaunchFooter(") && - sheetContent.indexOf("NovaGameDetailLaunchFooter(") > - sheetContent.indexOf(scrollBody) + scrollBody.length + "the rail is gated rather than decorative: Launch mode appears only when there is a real choice, and a review replaces the rail with its own answers", + actions.contains("if (showLaunchModeAction)") && + actions.contains("if (reviewExpanded)") && + actions.contains("onRetryHighFps") && + actions.contains("onResetProfile") ) assertFalse( - "the primary launch button must not remain inside the vertically scrolling launch-controls body", - launchControls.contains("text = playLabel") || - launchControls.contains("onClick = onPrimaryLaunch") - ) - assertTrue( - "game detail should keep host/render limits in the scrolling body above the pinned launch action", - launchControls.contains("LaunchProfilePrimaryNotice(") && - sheetContent.indexOf("LaunchControlsPanel(") < sheetContent.indexOf("NovaGameDetailLaunchFooter(") - ) - assertTrue( - "Heads up should explain evidence and the recommended next launch instead of showing one vague limited-by line", - launchControls.contains("text = summary.noticeDetail") && - launchControls.contains("text = summary.noticeRecommendation") - ) - assertTrue( - "Heads up should remain visible when only the new evidence or recommendation fields are available", - launchControls.contains("val hasNoticeContent = listOf(") && - launchControls.contains("summary.noticeDetail") && - launchControls.contains("summary.noticeRecommendation") && - launchControls.contains("if (!hasNoticeContent) return") && - launchControls.contains("text = notice.ifBlank { \"Launch profile adjusted\" }") - ) - assertTrue( - "near-target performance should use an explicit healthy status tone instead of warning styling", - launchControls.contains("summary.noticeTone == NovaLaunchProfileNoticeTone.HEALTHY") && - launchControls.contains("val badgeLabel = if (isHealthy) summary.noticeLabel else \"Heads up\"") && - launchControls.contains("colorResource(R.color.nova_success)") - ) - assertTrue( - "Heads up should stay compact until the player opens the DPAD-friendly detail control", - launchControls.contains("var noticeExpanded by remember(") && - launchControls.contains("text = if (noticeExpanded) \"Hide details\" else \"More details\"") && - launchControls.contains("stateDescription = if (noticeExpanded) \"Expanded\" else \"Collapsed\"") && - launchControls.contains("if (noticeExpanded && summary.noticeDetail.isNotBlank())") && - launchControls.contains("if (noticeExpanded && summary.noticeRecommendation.isNotBlank())") + "the primary launch button must not sit inside a scrolling body", + actions.contains("verticalScroll") ) } @@ -1239,52 +1184,32 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailUsesHeroBackdropLogoTransformIconIdentityAndPosterFallback() { val source = readNovaGameDetail() - val sheetContent = source.section( - "fun NovaGameDetailContent(", - "@Composable\nprivate fun NovaGameDetailScrollableContent(" + val overview = source.section( + "internal fun NovaGameDetailOverview(", + "private fun NovaGameDetailTitle(" ) - val detailsPanel = source.section( - "private fun GameDetailsPanel(", - "@Composable\nprivate fun LaunchControlsPanel(" + val title = source.section( + "private fun NovaGameDetailTitle(", + "private fun NovaGameDetailStatusLine(" ) assertTrue( - "detail content should route revision-aware Hero, Logo, and Icon presentation into the identity panel", - sheetContent.contains("heroAvailable = heroAvailable") && - sheetContent.contains("heroPresentationKey = heroPresentationKey") && - sheetContent.contains("heroLoader = heroLoader") && - sheetContent.contains("logoPresentationKey = logoPresentationKey") && - sheetContent.contains("iconPresentationKey = iconPresentationKey") - ) - assertTrue( - "a real manifest Hero should become the compact detail backdrop", - detailsPanel.contains("if (heroAvailable)") && - detailsPanel.contains("NovaGameDetailHero(") && - detailsPanel.contains("key(heroPresentationKey)") && - detailsPanel.contains("heroLoader(this)") && - detailsPanel.contains(".height(136.dp)") - ) - assertTrue( - "the manifest Logo should be revision-aware and use the saved normalized transform over Hero", - detailsPanel.contains("BoxWithConstraints(") && - detailsPanel.contains("key(logoPresentationKey)") && - detailsPanel.contains("logoLoader(this)") && - detailsPanel.contains("offset(x = logoOffsetX, y = logoOffsetY)") && - detailsPanel.contains("scaleX = artworkState.logoScale") && - detailsPanel.contains("scaleY = artworkState.logoScale") + "the hero should be the full-bleed backdrop rather than a 136dp panel thumbnail, reusing the library's own backdrop so hero-to-poster fallback and the theme scrims come with it", + overview.contains("NovaLibraryCinematicBackdrop(") && + overview.contains("strength = 1f") && + !overview.contains(".height(136.dp)") ) assertTrue( - "the manifest Icon should provide a compact revision-aware identity mark beside the title", - detailsPanel.contains("if (iconAvailable)") && - detailsPanel.contains("key(iconPresentationKey)") && - detailsPanel.contains("iconLoader(this)") && - detailsPanel.contains("text = game.name") + "curated logo artwork should become the title treatment at real size, still keyed by presentation revision", + title.contains("if (logoAvailable)") && + title.contains("key(logoPresentationKey)") && + title.contains("logoLoader(this)") && + title.contains("maxWidth = 200.dp, maxHeight = 64.dp") ) assertTrue( - "Poster must remain the no-Hero fallback instead of disappearing from detail presentation", - detailsPanel.contains("NovaGameDetailPosterFallback(") && - detailsPanel.contains("key(PolarisApiClient.artworkPresentationKey(game, PolarisGame.ARTWORK_KIND_POSTER))") && - detailsPanel.contains("coverLoader(this)") + "a game with no curated logo falls back to its name, and the fallback is a title rather than a poster card", + title.contains("text = game.name") && + title.contains("nova-game-detail-title") ) } @@ -1295,11 +1220,11 @@ class NovaComposeSourceGuardTest { "fun NovaGameDetailContent(", "@Composable\nprivate fun NovaGameDetailScrollableContent(" ) - val artworkIndex = content.indexOf("NovaArtworkStudio(") - val stabilityIndex = content.indexOf("optimizationState.stability?.let") - val hintIndex = content.indexOf("NovaControllerHintBar(") - assertTrue("artwork preferences should follow functional and insight content", artworkIndex > stabilityIndex) - assertTrue("artwork preferences should remain above the controller hint footer", artworkIndex in 1 until hintIndex) + assertTrue( + "artwork curation should be its own destination, and a full-screen one: the studio lays itself out as a Row of weighted Columns and cannot fold into a side panel", + content.contains("NovaGameDetailDestination.ARTWORK -> NovaGameDetailFullScreen(") && + content.contains("NovaArtworkStudio(") + ) val panel = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") assertTrue("artwork preferences should start collapsed", panel.contains("var expanded by remember(initialQuery) { mutableStateOf(false) }")) @@ -1751,11 +1676,10 @@ class NovaComposeSourceGuardTest { libraryScreen.contains("controllerHintBarLandscapeStartPadding") ) assertTrue( - "game detail sheet should keep the shared hint bar with explicit horizontal and bottom padding inside the scrollable sheet", - detailContent.contains("NovaControllerHintBar(") && - detailContent.contains("hints = novaGameDetailControllerHints()") && - detailContent.contains(".padding(start = 14.dp, end = 14.dp, top = 12.dp)") && - detailContent.contains(".padding(bottom = 16.dp)") + "the game detail window should keep the shared hint bar on the Overview floor and in every destination", + detail.contains("NovaControllerHintBar(") && + detail.contains("hints = novaGameDetailOverviewHints()") && + detail.contains("nova_controller_hint_back") ) assertTrue( "settings should keep the main rows weighted above the shared hint bar instead of letting rows consume and clip the bottom controls", @@ -2749,7 +2673,9 @@ class NovaComposeSourceGuardTest { private fun readNovaGameDetail(): String = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") private fun readNovaSettingsScreen(): String = readSource("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt") @@ -2772,7 +2698,9 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailLaunchOptionsAvoidRawAppCompatAlertDialogButtons() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val launchOptions = detail.section( "private fun showLaunchOptions(", "private fun optionLabel(" @@ -2789,7 +2717,9 @@ class NovaComposeSourceGuardTest { @Test fun gameDetailProfilePreferenceAvoidsRawAppCompatAlertDialogButtons() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val profileOptions = detail.section( "private fun showProfilePreferenceOptions(", "private fun steamLaunchModeOptionsState(" diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt index 3188ef34..31fb260c 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt @@ -10,14 +10,15 @@ class NovaLaunchSourceGuardTest { @Test fun gameDetailLaunchUsesSelectedMangoHudState() { - val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") - val primaryLaunch = detail.section("onPrimaryLaunch = {", "},\n onLaunchModeSelected") - val launchModeSelection = detail.section("fun selectLaunchMode(", "setContentView(") + val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + // launchConfirmed is shared by the primary action, the option picker and the + // expanded review, so the MangoHUD state is asserted where they all pass through. + val launchConfirmed = detail.section("fun launchConfirmed(", "fun resetProfile(") + val launchModeSelection = detail.section("fun selectLaunchMode(", "fun launchConfirmed(") assertTrue( - "primary Play should pass the selected MangoHUD state into the launch request", - primaryLaunch.contains("currentGame.copy(mangohud = mangoHudEnabled)") + "every launch path should pass the selected MangoHUD state into the launch request", + launchConfirmed.contains("currentGame.copy(mangohud = mangoHudEnabled)") ) assertTrue( "inline mode selection should keep the selected MangoHUD state in preview/preflight state", @@ -45,38 +46,40 @@ class NovaLaunchSourceGuardTest { @Test fun desktopSteamDecisionSheetUsesNovaGlassAndExplicitMirrorDesktopPlumbing() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt") val game = readSource("src/main/java/com/papi/nova/Game.kt") val streamConfiguration = readSource("src/main/java/com/papi/nova/nvstream/StreamConfiguration.kt") val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt") - val chrome = readSource("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt") - val decisionSheet = detail.section( - "private fun showDesktopSteamLaunchDecision(", - "private fun modeLabel(" - ) - val launchOptions = detail.section( - "onLaunchOptionSelected = { option ->", - "onDismissLaunchOptions =" + val decisionRows = detail.section( + "internal fun NovaDesktopSteamLaunchDecisionRows(", + "private fun NovaSteamChoiceRow(" ) assertTrue( - "desktop Steam active policy should open a Nova-themed Compose bottom sheet, not a legacy square AlertDialog", - decisionSheet.contains("BottomSheetDialog(this@NovaGameDetailActivity)") && - decisionSheet.contains("NovaDesktopSteamLaunchDecisionContent(") && - !decisionSheet.contains("AlertDialog.Builder") + "the desktop Steam choice belongs in the Launch mode destination, not a sheet or an alert raised over the artwork", + detail.contains("destination = NovaGameDetailDestination.LAUNCH_MODE") && + detail.contains("steamDecision = desktopSteamDecision") && + !detail.contains("BottomSheetDialog(") && + !detail.contains("AlertDialog.Builder") + ) + assertTrue( + "the decision should offer explicit Private Stream, Mirror Desktop, and close-Steam paths", + decisionRows.contains("nova_desktop_steam_private_stream") && + decisionRows.contains("nova_desktop_steam_mirror_desktop") && + decisionRows.contains("NovaSteamLaunchChoice.CLOSE_STEAM_THEN_PRIVATE") ) assertTrue( - "decision sheet should offer explicit Private Stream, Mirror Desktop, and Cancel actions", - decisionSheet.contains("nova_desktop_steam_private_stream") && - decisionSheet.contains("nova_desktop_steam_mirror_desktop") && - decisionSheet.contains("nova_desktop_steam_cancel") + "a blocked option stays visible and inert, because the reason it is blocked is the useful part", + decisionRows.contains("enabled = decision.privateStreamEnabled") && + decisionRows.contains("caption = decision.privateStreamUnavailableReason") ) assertTrue( "Launch Options must not bypass desktop-Steam safety for selected private headless launches", - launchOptions.contains("usesVirtualDisplay = option.usesVirtualDisplay") && - launchOptions.contains("showDesktopSteamLaunchDecision(") && - launchOptions.contains("onForcePrivateAfterSteamClose = { launchSelected(mirrorDesktop = false, forcePrivateAfterSteamClose = true) }") + detail.contains("usesVirtualDisplay = option.usesVirtualDisplay") && + detail.contains("steamDecision = desktopSteamDecision") ) assertTrue( "Mirror Desktop must be carried as an explicit launch override through the stream launch path", @@ -88,15 +91,16 @@ class NovaLaunchSourceGuardTest { streamConfiguration.contains("fun setMirrorDesktop(enable: Boolean)") && streamConfiguration.contains("fun setForcePrivateAfterSteamClose(enable: Boolean)") && nvHttp.contains("&mirrorDesktop=") && - nvHttp.contains("&launchMode=mirror_desktop") && - serverHelper.contains("forcePrivateAfterSteamClose = forcePrivateAfterSteamClose") + nvHttp.contains("&launchMode=mirror_desktop") ) } @Test fun launchFailureAndDesktopSteamActionsUseNovaThemedFlow() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val serverHelper = readSource("src/main/java/com/papi/nova/utils/ServerHelper.kt") val game = readSource("src/main/java/com/papi/nova/Game.kt") val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt") @@ -161,15 +165,22 @@ class NovaLaunchSourceGuardTest { @Test fun composeBottomSheetsUseThemeAwareGlassHostInsteadOfStaticOldThemeInset() { - val gameDetailSheet = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + val gameDetail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val syncSheet = readSource("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt") assertTrue( - "The desktop Steam sheet hosted by the game detail window must clear/style the Material host with shared Nova glass chrome so bottom/nav inset gaps do not show old static blue chrome", - gameDetailSheet.contains("NovaSheetChrome.applyBottomSheetChrome(bottomSheetDialog, contentView)") && - gameDetailSheet.contains("NovaSheetChrome.createSheetBackground(this@NovaGameDetailActivity)") && - !gameDetailSheet.contains("sheet.setBackgroundResource(sheetBackgroundRes())") + "the game detail window hosts no bottom sheet and no legacy alert at all, so there is no Material host left to restyle", + !gameDetail.contains("BottomSheetDialog(") && + !gameDetail.contains("AlertDialog.Builder") && + !gameDetail.contains("sheet.setBackgroundResource") + ) + assertTrue( + "a destination is a surface, not media: its glass must sit on an opaque ground or the artwork reads straight through it", + gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)") || + gameDetail.contains(".background(colors.window)\n .background(surfaces.panel)") ) assertTrue( "Polaris sync sheet must use the same theme-aware host chrome instead of static nova_sheet_bg inset background", @@ -181,7 +192,9 @@ class NovaLaunchSourceGuardTest { @Test fun virtualLaunchPreflightUsesHostVirtualDisplayContractConstants() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val trampoline = readSource("src/main/java/com/papi/nova/ShortcutTrampoline.kt") val displayMode = readSource("src/main/java/com/papi/nova/api/PolarisStreamDisplayMode.kt") @@ -343,7 +356,9 @@ class NovaLaunchSourceGuardTest { @Test fun displayPlannerAndPostSessionReportStayControllerFirstAndLowNoise() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val quickContent = readSource("src/main/java/com/papi/nova/ui/NovaQuickMenuContent.kt") val planner = readSource("src/main/java/com/papi/nova/ui/NovaDisplayResolutionPlanner.kt") val optionSheet = detail.section( @@ -489,7 +504,9 @@ class NovaLaunchSourceGuardTest { @Test fun virtualDisplayUnavailableCopyUsesHostVirtualDisplayLanguageAndReason() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val strings = readSource("src/main/res/values/strings.xml") assertTrue(strings.contains("nova_library_virtual_display_unavailable_title") && strings.contains("Host Virtual Display is not ready")) @@ -625,11 +642,10 @@ class NovaLaunchSourceGuardTest { @Test fun gameDetailPreflightPreservesExplicitPolarisNonVirtualMode() { - val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") val preflight = detail.section( "private fun syncLaunchPreflightSettings(", - "private fun showPreflightReview(" + "private fun modeLabel(" ) assertTrue( @@ -659,7 +675,9 @@ class NovaLaunchSourceGuardTest { @Test fun steamLaunchSelectionDoesNotDismissGameDetailOrStartStream() { val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") val selection = detail.section( "onSteamLaunchModeSelected = { selected ->", "},\n onDismissSteamLaunchModeOptions" @@ -674,7 +692,9 @@ class NovaLaunchSourceGuardTest { fun steamLaunchModeUpdateConfirmsHostModeAndStaysInline() { val api = readSource("src/main/java/com/papi/nova/api/PolarisApiClient.kt") val detail = readSource("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt") + - readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt") + + readSource("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt") assertTrue(api.contains("fun setSteamLaunchMode(gameId: String, mode: String): String?")) assertTrue(api.contains("json.optString(\"mode\", normalizedMode)")) diff --git a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt index 78c82273..44cb4a3f 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaThemeResourcesTest.kt @@ -184,7 +184,9 @@ class NovaThemeResourcesTest { val pcView = File("src/main/java/com/papi/nova/PcView.kt").readText() val appView = File("src/main/java/com/papi/nova/AppView.kt").readText() val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText() val polarisSync = File("src/main/java/com/papi/nova/ui/NovaPolarisSyncSheet.kt").readText() val library = File("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt").readText() val contextSheet = File("src/main/res/layout/nova_app_context_sheet.xml").readText() @@ -257,7 +259,9 @@ class NovaThemeResourcesTest { val sheetChrome = File("src/main/java/com/papi/nova/ui/NovaSheetChrome.kt").readText() val composeTheme = File("src/main/java/com/papi/nova/ui/compose/NovaComposeTheme.kt").readText() val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText() assertTrue("native sheet chrome must expose a named glass alpha contract", sheetChrome.contains("SHEET_GLASS_ALPHA")) assertTrue("native sheet backgrounds should preserve theme color while applying the absolute outer opacity", sheetChrome.contains("ColorUtils.setAlphaComponent") && sheetChrome.contains("NovaMenuPreferences.outerSurfaceAlpha")) @@ -390,7 +394,9 @@ class NovaThemeResourcesTest { val lifecycle = File("src/main/java/com/papi/nova/ui/NovaStreamOverlayContent.kt").readText() val library = File("src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt").readText() val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText() val settings = File("src/main/java/com/papi/nova/preferences/NovaSettingsScreen.kt").readText() val focusComponents = File("src/main/java/com/papi/nova/ui/compose/NovaFocusComponents.kt").readText() val settingsViewModel = File("src/main/java/com/papi/nova/preferences/NovaSettingsViewModel.kt").readText() @@ -453,14 +459,19 @@ class NovaThemeResourcesTest { @Test fun requiredNativeAlertsUseSharedOpacityAndBlurChrome() { val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText() val legacySlider = File("src/main/java/com/papi/nova/preferences/SeekBarPreference.kt").readText() val sessionDialog = File("src/main/java/com/papi/nova/utils/Dialog.kt").readText() val preflight = gameDetail .substringAfter("private fun showPreflightReview(") .substringBefore("private fun showDesktopSteamLaunchDecision(") - assertTrue("game-detail preflight should use shared literal-opacity alert chrome", preflight.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert")) + assertTrue( + "the game detail window raises no legacy alert at all: the preflight review expands the status line in place instead", + !preflight.contains("AlertDialog.Builder") && preflight.contains("reviewExpanded") + ) assertTrue("legacy sliders, including Menu & Drawer Opacity, should use shared literal-opacity alert chrome", legacySlider.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdDialog)")) assertTrue("session termination/error alerts should use shared literal-opacity alert chrome", sessionDialog.contains("NovaSheetChrome.applyMenuOpacityToLegacyAlert(createdAlert)")) } @@ -547,7 +558,9 @@ class NovaThemeResourcesTest { val pcView = File("src/main/java/com/papi/nova/PcView.kt").readText() val appView = File("src/main/java/com/papi/nova/AppView.kt").readText() val gameDetail = File("src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt").readText() + - File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + File("src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt").readText() + + File("src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt").readText() val manager = File("src/main/java/com/papi/nova/ui/NovaThemeManager.kt").readText() val particles = File("src/main/java/com/papi/nova/ui/SpaceParticleView.kt").readText() From ebdb0f6e25ca6d9e9fa448578b82d236815f8a51 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:21:49 -0400 Subject: [PATCH 02/24] feat(nova): paint the detail overview the way the concept specifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass reached for NovaActionButton and NovaControllerHintBar because they carry focus handling, theming and accessibility for free. They also carry the library's visual language, so the screen came out structurally right and visually wrong: a flat accent fill with no button glyphs, sentence-case status text, and a bordered hint container sitting on the artwork. The identity and status lines are instrument readouts now — uppercase, tracked, and with tabular figures, since those are measurements and the digits should line up rather than dance. The primary action carries the button it is bound to on an accent gradient; the rest are quiet, hairline-bordered and marked. The floor is borderless with the Polaris mark opposite the back hint, because a bordered container would be one more box on a screen whose point is that nothing sits on the artwork in a box. Focus stays a ring and a tint, never a scale or an offset. Two guards follow the repaint: the Overview paints the shared hint model itself rather than using the bordered bar, and the action lane is its own composable. --- .../papi/nova/ui/NovaGameDetailOverview.kt | 212 ++++++++++++++---- app/src/main/res/values/strings.xml | 1 + .../nova/ui/NovaComposeSourceGuardTest.kt | 11 +- 3 files changed, 180 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index 6e68c2f0..4efa05c7 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -2,17 +2,21 @@ package com.papi.nova.ui import android.widget.ImageView import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent @@ -21,8 +25,13 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.key import androidx.compose.ui.res.stringResource import androidx.compose.ui.Alignment @@ -30,8 +39,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -114,7 +126,7 @@ internal fun NovaGameDetailOverview( ) Text( - text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game), + text = novaGameDetailIdentityLine(sourceLabel, lastPlayedText, game).uppercase(), color = colors.textSecondary, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -169,16 +181,53 @@ internal fun NovaGameDetailOverview( modifier = Modifier.padding(top = 16.dp), ) - NovaControllerHintBar( - hints = novaGameDetailOverviewHints(), - compact = true, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - ) + NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) } } } +/** + * The floor: what the buttons do on the left, the Polaris mark on the right. Borderless, + * because a bordered container here would be one more box on a screen whose point is that + * nothing sits on the artwork in a box. + */ +@Composable +private fun NovaGameDetailFooter(modifier: Modifier = Modifier) { + val colors = LocalNovaComposeColors.current + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.testTag("nova-game-detail-footer"), + ) { + novaGameDetailOverviewHints().forEach { hint -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(end = 18.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colors.accent.copy(alpha = 0.22f)), + ) { + Text(hint.key, color = colors.textPrimary, fontSize = 9.sp, fontWeight = FontWeight.Bold) + } + Text(hint.label, color = colors.textSecondary, fontSize = 11.sp) + } + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.nova_polaris_wordmark), + color = colors.textMuted, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.20.em, + ) + } +} + /** * Curated logo artwork replaces the title outright when it is ready at first composition. * A logo that arrives later is ignored: swapping a settled title for one is a visible jump, @@ -249,11 +298,15 @@ private fun NovaGameDetailStatusLine( ) { Box(modifier = Modifier.size(7.dp).clip(RoundedCornerShape(percent = 50)).background(lamp)) Text( - text = novaGameDetailStatusText(uiState, summary), + text = novaGameDetailStatusText(uiState, summary).uppercase(), color = colors.textPrimary, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.11.em, + // these are measurements, so the digits line up rather than dance + style = LocalTextStyle.current.copy( + fontFeatureSettings = "tnum", + ), maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -284,15 +337,12 @@ private fun NovaGameDetailActions( verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { - NovaActionButton( + NovaGameDetailAction( text = playLabel, onClick = onPrimaryLaunch, enabled = uiState.playEnabled, primary = true, - minHeight = NovaGameDetailActionHeight, - cornerRadius = NovaGameDetailCornerRadius, - fontSize = 15.sp, - contentPadding = PaddingValues(horizontal = 22.dp, vertical = 12.dp), + glyph = stringResource(R.string.nova_controller_hint_a), modifier = Modifier .focusRequester(playFocusRequester) .testTag("nova-game-detail-primary"), @@ -300,45 +350,129 @@ private fun NovaGameDetailActions( if (reviewExpanded) { if (optimizationState.profileSummary?.showRetryHighFps == true) { - NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_retry_high_fps), onRetryHighFps) + NovaGameDetailAction( + text = stringResource(R.string.nova_library_retry_high_fps), + onClick = onRetryHighFps, + mark = "\u25B2", + ) } - NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_reset_game_profile), onResetProfile) + NovaGameDetailAction( + text = stringResource(R.string.nova_library_reset_game_profile), + onClick = onResetProfile, + mark = "\u21BA", + ) } else { if (showLaunchModeAction) { - NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_mode_title)) { - onDestination(NovaGameDetailDestination.LAUNCH_MODE) - } - } - NovaGameDetailSecondaryAction(stringResource(R.string.nova_library_launch_options_secondary)) { - onDestination(NovaGameDetailDestination.TUNE) - } - NovaGameDetailSecondaryAction(stringResource(R.string.nova_artwork_studio_title)) { - onDestination(NovaGameDetailDestination.ARTWORK) + NovaGameDetailAction( + text = stringResource(R.string.nova_library_launch_mode_title), + onClick = { onDestination(NovaGameDetailDestination.LAUNCH_MODE) }, + mark = "\u229E", + ) } + NovaGameDetailAction( + text = stringResource(R.string.nova_library_launch_options_secondary), + onClick = { onDestination(NovaGameDetailDestination.TUNE) }, + mark = "\u2699", + ) + NovaGameDetailAction( + text = stringResource(R.string.nova_artwork_studio_title), + onClick = { onDestination(NovaGameDetailDestination.ARTWORK) }, + mark = "\u25C8", + ) } } } +/** + * One action in the lane. The primary carries the button it is bound to and an accent + * gradient; the rest are quiet, hairline-bordered and marked. Focus is a ring and a + * tint — never a scale or an offset, which is the contract the poster cards settled on. + */ @Composable -private fun NovaGameDetailSecondaryAction(text: String, onClick: () -> Unit) { +private fun NovaGameDetailAction( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + primary: Boolean = false, + glyph: String? = null, + mark: String? = null, +) { + val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current - NovaActionButton( - text = text, - onClick = onClick, - minHeight = NovaGameDetailActionHeight, - cornerRadius = NovaGameDetailCornerRadius, - fontSize = 13.sp, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - modifier = Modifier - .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) - .background(surfaces.control.copy(alpha = 1f)), - ) + val interactionSource = remember { MutableInteractionSource() } + var focused by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(NovaGameDetailCornerRadius) + + val background = if (primary && enabled) { + Brush.linearGradient( + listOf( + colors.accent, + lerp(colors.accent, Color.White, 0.28f), + lerp(colors.accent, Color.White, 0.62f), + ), + ) + } else { + SolidColor(surfaces.control.copy(alpha = 1f)) + } + val label = when { + primary && enabled -> colors.onAccent + enabled -> colors.textPrimary + else -> colors.textMuted + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + modifier = modifier + .heightIn(min = NovaGameDetailActionHeight) + .clip(shape) + .background(background, shape) + .border( + width = if (focused) 2.dp else 1.dp, + color = if (focused) colors.accent else surfaces.tileBorder, + shape = shape, + ) + .onFocusChanged { focused = it.isFocused } + .clickable( + enabled = enabled, + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .semantics { contentDescription = text } + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + if (glyph != null) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(20.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(colors.window.copy(alpha = 0.88f)), + ) { + Text( + text = glyph, + color = colors.textPrimary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + } + } + if (mark != null) { + Text(text = mark, color = label.copy(alpha = 0.62f), fontSize = 13.sp) + } + Text( + text = text, + color = label, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } -/** - * The alert this replaces stated the reason and offered launch, retry and reset. Expanded - * in place it keeps all four, without a dialog landing on the artwork. - */ @Composable private fun LaunchProfileReviewNotice( optimizationState: NovaGameDetailOptimizationState, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 151b520e..ce38b6c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,7 @@ Version and client identity. %1$s System + ✦ POLARIS A B X diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index 192cf7a3..1aa074f4 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -1079,7 +1079,7 @@ class NovaComposeSourceGuardTest { ) val actions = detail.section( "private fun NovaGameDetailActions(", - "private fun NovaGameDetailSecondaryAction(" + "private fun NovaGameDetailAction(" ) assertTrue( @@ -1097,7 +1097,7 @@ class NovaComposeSourceGuardTest { assertTrue( "every focusable action clears the accessible target floor", detail.contains("internal val NovaGameDetailActionHeight = 48.dp") && - actions.contains("minHeight = NovaGameDetailActionHeight") + detail.contains("heightIn(min = NovaGameDetailActionHeight)") ) assertTrue( "the rail is gated rather than decorative: Launch mode appears only when there is a real choice, and a review replaces the rail with its own answers", @@ -1676,9 +1676,10 @@ class NovaComposeSourceGuardTest { libraryScreen.contains("controllerHintBarLandscapeStartPadding") ) assertTrue( - "the game detail window should keep the shared hint bar on the Overview floor and in every destination", - detail.contains("NovaControllerHintBar(") && - detail.contains("hints = novaGameDetailOverviewHints()") && + "the game detail window keeps the shared hint model; the Overview paints it borderless on the artwork while destinations keep the reusable bar", + detail.contains("List") && + detail.contains("novaGameDetailOverviewHints()") && + detail.contains("NovaControllerHintBar(") && detail.contains("nova_controller_hint_back") ) assertTrue( From 49256201db116142ad7c290ed8562206294135ac Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:33:48 -0400 Subject: [PATCH 03/24] feat(nova): give the detail destinations their own headers and grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fidelity pass stopped at the Overview, so the destinations still carried habits from the sheet. Launch mode's eyebrow and headline both read Launch Mode, and Tune's headline repeated the button the user had just pressed rather than naming the profile it is about to change. The rail and the destination now call the same place the same thing: Tune. Launch mode says what it is for. Tune leads with the profile and separates state you read from actions you take, so a readout like the Steam launch mode no longer sits in the same shape as Reset Game Profile — one is a statement, the other has consequences. --- .../com/papi/nova/ui/NovaGameDetailContent.kt | 27 ++++++-- .../nova/ui/NovaGameDetailDestinations.kt | 62 +++++++++++++++++++ .../papi/nova/ui/NovaGameDetailOverview.kt | 2 +- app/src/main/res/values/strings.xml | 4 ++ 4 files changed, 88 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 69c1a9bf..a344bf57 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -320,8 +320,12 @@ internal fun NovaGameDetailContent( NovaGameDetailDestination.LAUNCH_MODE -> NovaGameDetailPanel( eyebrow = stringResource(R.string.nova_library_launch_mode_title), - headline = launchModeTitle, - readout = optimizationState.profileSummary?.selectedLine.orEmpty(), + headline = stringResource(R.string.nova_game_detail_where_it_runs), + readout = if (steamDecision != null) { + stringResource(R.string.nova_desktop_steam_title) + } else { + optimizationState.profileSummary?.selectedLine.orEmpty() + }, scrollState = verticalScroll, ) { val decision = steamDecision @@ -362,12 +366,15 @@ internal fun NovaGameDetailContent( } NovaGameDetailDestination.TUNE -> NovaGameDetailPanel( - eyebrow = stringResource(R.string.nova_library_launch_options_secondary), - headline = optimizationState.profileSummary?.primaryLaunchLabel.orEmpty() - .ifBlank { profilePreferenceLabel }, - readout = optimizationState.profileSummary?.freshnessLine.orEmpty(), + eyebrow = stringResource(R.string.nova_game_detail_tune), + headline = profilePreferenceLabel, + readout = listOf( + optimizationState.profileSummary?.selectedLine, + optimizationState.profileSummary?.freshnessLine, + ).filter { !it.isNullOrBlank() }.joinToString(" · "), scrollState = verticalScroll, ) { + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state)) profileOptionsState?.let { NovaProfilePreferenceSheet( state = it, @@ -399,6 +406,14 @@ internal fun NovaGameDetailContent( } optimizationState.ai?.let { InsightCard(card = it) } optimizationState.stability?.let { InsightCard(card = it) } + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions)) + LaunchProfileSummaryActions( + summary = optimizationState.profileSummary, + resetProfileLabel = resetProfileLabel, + resetProfileWorking = resetProfileWorking, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + ) } // The studio opens with a Row of weighted Columns, so it needs the window diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index dd0b82c9..dfab906d 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -172,6 +173,67 @@ private fun NovaGameDetailDestinationHeader(eyebrow: String, headline: String, r } } +/** + * Divides what you read from what you do. The sheet presented both as one list, so a + * readout like "MangoHUD: On" sat in the same shape as "Reset profile" — one is a + * statement, the other has consequences. + */ +@Composable +internal fun NovaGameDetailGroupLabel(text: String) { + val colors = LocalNovaComposeColors.current + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 6.dp), + ) { + Text( + text = text.uppercase(), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.22.em, + ) + Box( + modifier = Modifier + .weight(1f) + .height(1.dp) + .background(colors.divider.copy(alpha = 0.5f)), + ) + } +} + +/** Retry and reset: the two things in Tune that change something rather than report it. */ +@Composable +internal fun LaunchProfileSummaryActions( + summary: NovaLaunchProfileSummary?, + resetProfileLabel: String, + resetProfileWorking: Boolean, + onRetryHighFps: () -> Unit, + onResetProfile: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (summary?.showRetryHighFps == true) { + NovaSteamChoiceRow( + label = summary.retryHighFpsLabel.ifBlank { + stringResource(R.string.nova_library_retry_high_fps) + }, + caption = "", + enabled = true, + onClick = onRetryHighFps, + ) + } + NovaSteamChoiceRow( + label = resetProfileLabel, + caption = "", + enabled = !resetProfileWorking, + onClick = onResetProfile, + ) + } +} + /** * The desktop-Steam choice, as rows in Launch mode rather than a sheet over the artwork. * A blocked option stays visible and inert: hiding it loses the reason it is blocked, diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index 4efa05c7..41dc0a00 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -370,7 +370,7 @@ private fun NovaGameDetailActions( ) } NovaGameDetailAction( - text = stringResource(R.string.nova_library_launch_options_secondary), + text = stringResource(R.string.nova_game_detail_tune), onClick = { onDestination(NovaGameDetailDestination.TUNE) }, mark = "\u2699", ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ce38b6c1..0471b43d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,10 @@ Version and client identity. %1$s System + Tune + Where it runs + State + Actions ✦ POLARIS A B From e1a4f3a287230dd0a0f7017f4d40fef8a327b73a Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:12:16 -0400 Subject: [PATCH 04/24] feat(nova): let the primary action follow the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Play on a game that is already running is wrong twice over: it relaunches what could have been resumed, and on someone else's session it launches over them. The window can see the session — it holds the api client — so the verb is derived rather than assumed: Watch when another device owns it, Resume when you do, and the launch label otherwise. Yours also gains End session. Resuming and ending both need stream credentials the window does not carry, so it returns the intent and the library performs it, which is the contract the launch already uses. Making that reachable needed a library change. While a session is live the library drops that game from the grid and shows it only in the resume row, whose card resumed along with its button — so the running game was the one entry whose detail could not be opened at all. The card now opens the game and the buttons act on it, which is what the card already did everywhere else. Verified against a live Control session on the Retroid: Resume and End session both appear, and End reaches the host through the result contract and its confirmation. --- .../papi/nova/ui/NovaGameDetailComposeTest.kt | 4 ++ .../papi/nova/ui/NovaGameDetailActivity.kt | 37 +++++++++++++++++++ .../com/papi/nova/ui/NovaGameDetailContent.kt | 6 +++ .../papi/nova/ui/NovaGameDetailOverview.kt | 31 ++++++++++++++-- .../com/papi/nova/ui/NovaLibraryActivity.kt | 25 ++++++++++++- app/src/main/res/values/strings.xml | 3 ++ .../nova/ui/NovaComposeSourceGuardTest.kt | 8 ++-- 7 files changed, 105 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt index 990ebdd1..70e0801c 100644 --- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt +++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt @@ -126,6 +126,10 @@ class NovaGameDetailComposeTest { sourceLabel = "Steam", onDestination = {}, onSteamChoice = {}, + // no session under test, so the primary action stays a launch + activeSession = null, + onResumeSession = {}, + onEndSession = {}, ) } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index 1f0ea738..2a3260ba 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -153,6 +153,17 @@ class NovaGameDetailActivity : NovaActivity() { private val onGameUpdated: ((PolarisGame) -> Unit)? = { game -> updatedGame = game } + /** Resume and End need stream credentials this window does not carry, so it asks. */ + private fun finishWithSessionRequest(request: String) { + setResult( + RESULT_OK, + Intent() + .putExtra(EXTRA_RESULT_SESSION, request) + .putExtra(EXTRA_RESULT_GAME, updatedGame?.let { PolarisGameJson.encode(it) }), + ) + finish() + } + private val onRefreshArtwork: ((PolarisGame, (NovaArtworkMutationResult) -> Unit) -> Unit)? = { game, onResult -> artworkViewModel.refreshArtwork(game = game, onResult = onResult) } @@ -187,6 +198,9 @@ class NovaGameDetailActivity : NovaActivity() { /** The preflight review, expanded on the Overview rather than raised as an alert. */ private var reviewExpanded by mutableStateOf(false) + /** The host's session, when it is this game's. Null means nothing is running. */ + private var activeSession by mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { NovaThemeManager.applyTheme(this) super.onCreate(savedInstanceState) @@ -225,6 +239,7 @@ class NovaGameDetailActivity : NovaActivity() { ) setUpDetail(game, apiClient) + refreshActiveSession(game) } /** @@ -244,6 +259,22 @@ class NovaGameDetailActivity : NovaActivity() { else -> false } + /** + * Polaris reports one session at a time, so it only matters here when it is this + * game's. Matched on the UUID Polaris uses, falling back to the numeric app id. + */ + private fun refreshActiveSession(game: PolarisGame) { + lifecycleScope.launch { + val session = withContext(Dispatchers.IO) { + runCatching { NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus()) } + .getOrNull() + } + activeSession = session?.takeIf { + it.gameUuid.equals(game.id, ignoreCase = true) || it.gameId == game.appId + } + } + } + /** Carries artwork or MangoHUD edits back even when the window closes without launching. */ private fun publishGameUpdate() { val game = updatedGame ?: return @@ -485,6 +516,9 @@ class NovaGameDetailActivity : NovaActivity() { apiClient = apiClient, sourceLabel = currentGame.sourceLabel, onDestination = { next -> destination = next }, + activeSession = activeSession, + onResumeSession = { finishWithSessionRequest(RESULT_SESSION_RESUME) }, + onEndSession = { finishWithSessionRequest(RESULT_SESSION_END) }, onSteamChoice = { choice -> when (choice) { NovaSteamLaunchChoice.PRIVATE_STREAM -> @@ -1335,6 +1369,9 @@ class NovaGameDetailActivity : NovaActivity() { const val EXTRA_DEFAULT_VIRTUAL_DISPLAY = "nova.detail.defaultVirtualDisplay" const val EXTRA_RESULT_LAUNCH = "nova.detail.result.launch" const val EXTRA_RESULT_LAUNCH_GAME = "nova.detail.result.launchGame" + const val EXTRA_RESULT_SESSION = "nova.detail.result.session" + const val RESULT_SESSION_RESUME = "resume" + const val RESULT_SESSION_END = "end" const val EXTRA_RESULT_GAME = "nova.detail.result.game" const val RESULT_KEY_VIRTUAL_DISPLAY = "virtualDisplay" diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index a344bf57..81323661 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -289,6 +289,9 @@ internal fun NovaGameDetailContent( sourceLabel: String, onDestination: (NovaGameDetailDestination) -> Unit, onSteamChoice: (NovaSteamLaunchChoice) -> Unit, + activeSession: NovaLibraryActiveSessionUiState?, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, ) { val verticalScroll = rememberScrollState() val playFocusRequester = remember { FocusRequester() } @@ -313,6 +316,9 @@ internal fun NovaGameDetailContent( onRetryHighFps = onRetryHighFps, onResetProfile = onResetProfile, onDestination = onDestination, + activeSession = activeSession, + onResumeSession = onResumeSession, + onEndSession = onEndSession, ) when (destination) { diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index 41dc0a00..3d28df46 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -102,6 +102,9 @@ internal fun NovaGameDetailOverview( onRetryHighFps: () -> Unit, onResetProfile: () -> Unit, onDestination: (NovaGameDetailDestination) -> Unit, + activeSession: NovaLibraryActiveSessionUiState?, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, modifier: Modifier = Modifier, ) { val colors = LocalNovaComposeColors.current @@ -178,6 +181,9 @@ internal fun NovaGameDetailOverview( onRetryHighFps = onRetryHighFps, onResetProfile = onResetProfile, onDestination = onDestination, + activeSession = activeSession, + onResumeSession = onResumeSession, + onEndSession = onEndSession, modifier = Modifier.padding(top = 16.dp), ) @@ -330,6 +336,9 @@ private fun NovaGameDetailActions( onRetryHighFps: () -> Unit, onResetProfile: () -> Unit, onDestination: (NovaGameDetailDestination) -> Unit, + activeSession: NovaLibraryActiveSessionUiState?, + onResumeSession: () -> Unit, + onEndSession: () -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -337,17 +346,31 @@ private fun NovaGameDetailActions( verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { + // Precedence: someone else's session, then yours, then the ordinary launch. + // Launching over a session another device owns would take their display. NovaGameDetailAction( - text = playLabel, - onClick = onPrimaryLaunch, - enabled = uiState.playEnabled, - primary = true, + text = when { + activeSession?.watchOnly == true -> stringResource(R.string.nova_game_detail_watch) + activeSession != null -> stringResource(R.string.nova_game_detail_resume) + else -> playLabel + }, + onClick = if (activeSession != null) onResumeSession else onPrimaryLaunch, + enabled = uiState.playEnabled || activeSession != null, + primary = activeSession?.watchOnly != true, glyph = stringResource(R.string.nova_controller_hint_a), modifier = Modifier .focusRequester(playFocusRequester) .testTag("nova-game-detail-primary"), ) + if (activeSession != null && !activeSession.watchOnly) { + NovaGameDetailAction( + text = stringResource(R.string.nova_game_detail_end_session), + onClick = onEndSession, + mark = "◼", + ) + } + if (reviewExpanded) { if (optimizationState.profileSummary?.showRetryHighFps == true) { NovaGameDetailAction( diff --git a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt index ddcd916e..bdeb2ead 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaLibraryActivity.kt @@ -712,6 +712,13 @@ class NovaLibraryActivity : NovaActivity() { return consumed } + private fun queryActiveSessionAsync(onResult: (NovaLibraryActiveSessionUiState?) -> Unit) { + lifecycleScope.launch { + val session = withContext(Dispatchers.IO) { runCatching { queryActiveSession() }.getOrNull() } + onResult(session) + } + } + private fun queryActiveSession(): NovaLibraryActiveSessionUiState? { return NovaLibraryActiveSessionUiState.from(apiClient.getSessionStatus()) } @@ -797,6 +804,14 @@ class NovaLibraryActivity : NovaActivity() { data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_GAME) ?.let { PolarisGameJson.decode(it) } ?.let { updated -> allGames = allGames.map { if (it.id == updated.id) updated else it } } + when (data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_SESSION)) { + // The window saw the session but cannot act on it: resuming and ending both + // need stream credentials that live here. + NovaGameDetailActivity.RESULT_SESSION_RESUME -> + queryActiveSessionAsync { session -> session?.let { resumeActiveSession(it) } } + NovaGameDetailActivity.RESULT_SESSION_END -> + queryActiveSessionAsync { session -> session?.let { endActiveSession(it) } } + } val launch = data.getStringExtra(NovaGameDetailActivity.EXTRA_RESULT_LAUNCH) ?: return val request = try { @@ -1287,6 +1302,7 @@ class NovaLibraryActivity : NovaActivity() { null -> Unit } }, + onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } }, onGameFocused = onGameFocused ) } @@ -1365,6 +1381,7 @@ class NovaLibraryActivity : NovaActivity() { null -> Unit } }, + onOpenDetail = model.hero.game?.let { game -> { onOpenDetail(game) } }, onGameFocused = onGameFocused ) } @@ -1520,6 +1537,12 @@ class NovaLibraryActivity : NovaActivity() { apiClient: PolarisApiClient, onPrimaryAction: () -> Unit, onSecondaryAction: (() -> Unit)? = null, + /** + * The card opens the game; the buttons do the thing. Without this the running + * game was the one entry whose detail could not be reached at all, because the + * whole card resumed and the grid omits it while a session is live. + */ + onOpenDetail: (() -> Unit)? = null, onGameFocused: (PolarisGame) -> Unit ) { val colors = LocalNovaComposeColors.current @@ -1562,7 +1585,7 @@ class NovaLibraryActivity : NovaActivity() { .onFocusChanged { focused = it.isFocused || it.hasFocus } - .combinedClickable(onClick = onPrimaryAction) + .combinedClickable(onClick = onOpenDetail ?: onPrimaryAction) .focusable() .padding(if (compact) 8.dp else 16.dp), horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 16.dp), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0471b43d..f59c49d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,9 @@ Version and client identity. %1$s System + Resume + Watch + End session Tune Where it runs State diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index 1aa074f4..85ace9e7 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -575,9 +575,9 @@ class NovaComposeSourceGuardTest { hero.contains("if (!compact && hero.badges.isNotEmpty())") ) assertTrue( - "hero card itself should activate the same primary action when D-pad focus lands on the container", - hero.contains(".combinedClickable(onClick = onPrimaryAction)") && - hero.indexOf(".combinedClickable(onClick = onPrimaryAction)") in 0 until hero.indexOf(".focusable()") + "hero card opens the game and falls back to the primary action, so a running game is still reachable while the grid omits it", + hero.contains(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") && + hero.indexOf(".combinedClickable(onClick = onOpenDetail ?: onPrimaryAction)") in 0 until hero.indexOf(".focusable()") ) assertTrue( "hero focus should update the focused backdrop/focus restore model for D-pad users", @@ -1086,7 +1086,7 @@ class NovaComposeSourceGuardTest { "the primary action holds first focus, and nothing scrolls above it", detail.contains("val playFocusRequester = remember { FocusRequester() }") && actions.contains(".focusRequester(playFocusRequester)") && - actions.contains("primary = true") && + actions.contains("primary = activeSession?.watchOnly != true") && !overview.contains("verticalScroll") ) assertTrue( From 03b17acc44608291d8b1e917ff8efcfd7fc22e0b Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:26:38 -0400 Subject: [PATCH 05/24] feat(nova): give the detail overview a portrait layout Anchoring the content bottom-left is a landscape assumption. On a phone it stranded everything at the floor of a very tall screen, and it cropped a 3:1 Steam hero into a 0.45:1 viewport, which showed a narrow vertical slice of hair rather than a subject. Portrait now puts the hero in a 16:9 band that dissolves into the window colour, sets the content directly beneath it, stacks the actions at a shared full width so labels stop truncating, and pins the floor to the bottom of the screen instead of trailing the buttons. Verified on a Pixel 10 Pro under Material You, which also covers the theme whose accent is arbitrary because it comes from the wallpaper. The Retroid could not test any of this: its panel is hardware-mounted landscape and ignores rotation. The band leaves empty ground beneath the actions. That is where the playtime and How Long To Beat gauge goes once Polaris can supply it; filler would be worse. --- .../papi/nova/ui/NovaGameDetailOverview.kt | 91 +++++++++++++++++-- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index 3d28df46..df184599 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -7,12 +7,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -37,6 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -110,13 +113,31 @@ internal fun NovaGameDetailOverview( val colors = LocalNovaComposeColors.current val game = uiState.game - Box(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) { - NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f) + BoxWithConstraints(modifier = modifier.fillMaxSize().testTag("nova-game-detail-overview")) { + val portrait = maxHeight > maxWidth + + if (portrait) { + // A 3:1 hero cropped to a phone's aspect shows a slice, not a subject, so it + // gets a 16:9 band and dissolves into the ground rather than filling behind. + NovaLibraryCinematicBackdrop( + game = game, + apiClient = apiClient, + strength = 1f, + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .aspectRatio(16f / 9f) + .novaFadeToGround(colors.window), + ) + } else { + NovaLibraryCinematicBackdrop(game = game, apiClient = apiClient, strength = 1f) + } Column( modifier = Modifier - .align(Alignment.BottomStart) + .align(if (portrait) Alignment.TopStart else Alignment.BottomStart) .fillMaxWidth() + .then(if (portrait) Modifier.padding(top = 176.dp) else Modifier) .windowInsetsPadding(WindowInsets.safeContent) .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, bottom = 10.dp), ) { @@ -171,6 +192,7 @@ internal fun NovaGameDetailOverview( } NovaGameDetailActions( + stacked = portrait, uiState = uiState, optimizationState = optimizationState, playLabel = playLabel, @@ -187,7 +209,20 @@ internal fun NovaGameDetailOverview( modifier = Modifier.padding(top = 16.dp), ) - NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) + if (!portrait) { + NovaGameDetailFooter(modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) + } + } + + // On a tall screen the floor belongs at the bottom, not trailing the actions. + if (portrait) { + NovaGameDetailFooter( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent) + .padding(horizontal = NovaGameDetailInset, vertical = 10.dp), + ) } } @@ -326,6 +361,7 @@ private fun NovaGameDetailStatusLine( */ @Composable private fun NovaGameDetailActions( + stacked: Boolean, uiState: NovaGameDetailUiState, optimizationState: NovaGameDetailOptimizationState, playLabel: String, @@ -341,11 +377,27 @@ private fun NovaGameDetailActions( onEndSession: () -> Unit, modifier: Modifier = Modifier, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { + val lane: @Composable (@Composable () -> Unit) -> Unit = { content -> + if (stacked) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = modifier.fillMaxWidth(), + content = { content() }, + ) + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + content = { content() }, + ) + } + } + + // Stacked buttons sized to their own labels look ragged; in a column they share a width. + val itemWidth: Modifier = if (stacked) Modifier.fillMaxWidth() else Modifier + + lane { // Precedence: someone else's session, then yours, then the ordinary launch. // Launching over a session another device owns would take their display. NovaGameDetailAction( @@ -358,7 +410,7 @@ private fun NovaGameDetailActions( enabled = uiState.playEnabled || activeSession != null, primary = activeSession?.watchOnly != true, glyph = stringResource(R.string.nova_controller_hint_a), - modifier = Modifier + modifier = itemWidth .focusRequester(playFocusRequester) .testTag("nova-game-detail-primary"), ) @@ -368,6 +420,7 @@ private fun NovaGameDetailActions( text = stringResource(R.string.nova_game_detail_end_session), onClick = onEndSession, mark = "◼", + modifier = itemWidth, ) } @@ -377,12 +430,14 @@ private fun NovaGameDetailActions( text = stringResource(R.string.nova_library_retry_high_fps), onClick = onRetryHighFps, mark = "\u25B2", + modifier = itemWidth, ) } NovaGameDetailAction( text = stringResource(R.string.nova_library_reset_game_profile), onClick = onResetProfile, mark = "\u21BA", + modifier = itemWidth, ) } else { if (showLaunchModeAction) { @@ -390,22 +445,38 @@ private fun NovaGameDetailActions( text = stringResource(R.string.nova_library_launch_mode_title), onClick = { onDestination(NovaGameDetailDestination.LAUNCH_MODE) }, mark = "\u229E", + modifier = itemWidth, ) } NovaGameDetailAction( text = stringResource(R.string.nova_game_detail_tune), onClick = { onDestination(NovaGameDetailDestination.TUNE) }, mark = "\u2699", + modifier = itemWidth, ) NovaGameDetailAction( text = stringResource(R.string.nova_artwork_studio_title), onClick = { onDestination(NovaGameDetailDestination.ARTWORK) }, mark = "\u25C8", + modifier = itemWidth, ) } } } +/** Dissolves the hero band into the window colour instead of cutting against it. */ +private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0.62f to Color.Transparent, + 1.0f to ground, + ), + ), + ) +} + /** * One action in the lane. The primary carries the button it is bound to and an accent * gradient; the rest are quiet, hairline-bordered and marked. Focus is a ring and a From 557f043744cc7f9f2f93a85aba2bc9b01c70c35f Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:51:47 -0400 Subject: [PATCH 06/24] fix(nova): stop the detail destinations squishing their content A phone in landscape is barely 430dp tall and the Retroid is 468dp. A header sized for a tall viewport ate a third of that before any content, so Tune showed one and a half cards and clipped Safer Fallback. Below 500dp the eyebrow and headline say the same thing twice over, so they collapse to one line and the room goes to the content they introduce. The insight card had two separate faults meeting in one row. It titles itself from the profile label and then opened its provenance chip with the same word, the same duplication the destination headers had. And that provenance is six facts joined by separators, a metadata line rather than a tag, so a chip could only ever ellipsise it. The line moves under the title where it can wrap, and the reasoning gets the room the scroll already had. Verified on the Retroid against a live host: Tune now shows Steam Launch, Holding and Safer Fallback together, the provenance reads to its end, and the explanation finishes its sentence. --- .../papi/nova/ui/NovaGameDetailActivity.kt | 2 +- .../com/papi/nova/ui/NovaGameDetailContent.kt | 35 +++++++----- .../nova/ui/NovaGameDetailDestinations.kt | 54 ++++++++++++------- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index 2a3260ba..5c2c5df0 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -1216,7 +1216,7 @@ class NovaGameDetailActivity : NovaActivity() { "" } val sourceText = listOf( - stateLabel.takeIf { it.isNotBlank() }, + stateLabel.takeIf { it.isNotBlank() && stateLabel != titleLabel }, profileState?.optString("preference_label", "")?.takeIf { it.isNotBlank() }, lastResultText.takeIf { it.isNotBlank() }, sourceLabel.takeIf { it.isNotBlank() && sourceLabel != titleLabel }, diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 81323661..04f27d13 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -373,7 +373,9 @@ internal fun NovaGameDetailContent( NovaGameDetailDestination.TUNE -> NovaGameDetailPanel( eyebrow = stringResource(R.string.nova_game_detail_tune), - headline = profilePreferenceLabel, + headline = stringResource( + AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference), + ), readout = listOf( optimizationState.profileSummary?.selectedLine, optimizationState.profileSummary?.freshnessLine, @@ -1739,7 +1741,7 @@ private fun SteamLaunchModeCard( color = if (warning) colors.warning else colors.textSecondary, fontSize = 9.sp, lineHeight = 12.sp, - maxLines = 2, + maxLines = 3, overflow = TextOverflow.Ellipsis ) } @@ -1796,19 +1798,26 @@ private fun InsightCard(card: NovaGameDetailInsightCard) { contentPadding = PaddingValues(12.dp) ) { Column { - Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = card.label, + color = if (card.isWarning) colors.warning else colors.accent, + fontSize = if (card.isWarning) 13.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (card.source.isNotBlank()) { + // Six facts joined by separators is a metadata line, not a tag; in a chip + // it could only ellipsise, so it wraps under the title instead. Text( - text = card.label, - color = if (card.isWarning) colors.warning else colors.accent, - fontSize = if (card.isWarning) 13.sp else 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, + text = card.source, + modifier = Modifier.padding(top = 2.dp), + color = colors.textMuted, + fontSize = 10.sp, + lineHeight = 13.sp, + maxLines = 2, overflow = TextOverflow.Ellipsis ) - if (card.source.isNotBlank()) { - Spacer(modifier = Modifier.width(8.dp)) - NovaBadge(text = card.source, color = colors.textMuted) - } } Text( text = card.settings, @@ -1827,7 +1836,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) { color = colors.textMuted, fontSize = 10.sp, lineHeight = 13.sp, - maxLines = 3, + maxLines = 5, overflow = TextOverflow.Ellipsis ) } diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index dfab906d..766749a9 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -61,19 +62,24 @@ internal fun NovaGameDetailPanel( val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current - Box(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) { + // The panel exists so the game stays visible beside what you are changing. In + // portrait there is nothing to sit beside, so it takes the window instead of + // squeezing a phone-width column inside a phone. + val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION + val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT Column( modifier = Modifier .align(Alignment.CenterEnd) .fillMaxHeight() - .fillMaxWidth(NOVA_DETAIL_PANEL_WIDTH_FRACTION) + .fillMaxWidth(widthFraction) .background(colors.window) .background(surfaces.panel) .windowInsetsPadding(WindowInsets.safeContent) - .padding(horizontal = NovaGameDetailInset, vertical = 20.dp) + .padding(horizontal = NovaGameDetailInset, vertical = if (shortViewport) 10.dp else 20.dp) .testTag("nova-game-detail-panel"), ) { - NovaGameDetailDestinationHeader(eyebrow, headline, readout) + NovaGameDetailDestinationHeader(eyebrow, headline, readout, compact = shortViewport) Column( modifier = Modifier .weight(1f) @@ -108,7 +114,7 @@ internal fun NovaGameDetailFullScreen( .padding(horizontal = NovaGameDetailInset, vertical = 20.dp) .testTag("nova-game-detail-fullscreen"), ) { - NovaGameDetailDestinationHeader(eyebrow, headline, readout = "") + NovaGameDetailDestinationHeader(eyebrow, headline, readout = "", compact = false) Column( modifier = Modifier .weight(1f) @@ -140,26 +146,33 @@ private fun NovaGameDetailDestinationHints() { } @Composable -private fun NovaGameDetailDestinationHeader(eyebrow: String, headline: String, readout: String) { +private fun NovaGameDetailDestinationHeader( + eyebrow: String, + headline: String, + readout: String, + compact: Boolean = false, +) { val colors = LocalNovaComposeColors.current - Column(modifier = Modifier.padding(bottom = 14.dp)) { - Text( - text = eyebrow, - color = colors.textMuted, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.22.em, - ) + Column(modifier = Modifier.padding(bottom = if (compact) 6.dp else 14.dp)) { + if (!compact) { + Text( + text = eyebrow, + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.22.em, + ) + } Text( - text = headline, + text = if (compact) "$eyebrow · $headline" else headline, color = colors.textPrimary, - fontSize = 22.sp, + fontSize = if (compact) 15.sp else 22.sp, fontWeight = FontWeight.Bold, - maxLines = 2, + maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 3.dp), + modifier = Modifier.padding(top = if (compact) 0.dp else 3.dp), ) - if (readout.isNotBlank()) { + if (readout.isNotBlank() && !compact) { Text( text = readout, color = colors.textSecondary, @@ -343,3 +356,6 @@ private fun NovaSteamChoiceRow( * truncated at 53%, and still narrow enough to keep the game present beside it. */ private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f + +/** Below this a phone in landscape has no height to spare for chrome. */ +private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp From 868dee4c68d96268e7efed3880abd85c509ba901 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:07:29 -0400 Subject: [PATCH 07/24] feat(nova): rework the detail destinations to use the space they have The drill-ins sat on an opaque ground, which read as a separate screen rather than a layer over the game. The side panel is now translucent and leans on the dimmed game beside it for separation. The full-screen studio keeps a solid ground: with no outside to contrast against, translucency only printed the Overview through the artwork controls. Back was the only way out. The dimmed area beside the panel now dismisses on tap, and every destination carries a close control for the cases where there is no outside to tap, which is portrait and the studio. Tune wasted its width. A 500dp landscape body ran one narrow column and pushed the actions below the fold. Splitting it by category put State against Insight, which looked balanced and was not: one short card against two of prose, so the left half sat empty while the right half wrapped every line. The row now carries the two short things, State and Actions, and the prose spans the full width where it reads. The studio opens expanded when it is the whole window, since starting collapsed there costs a tap and leaves the window empty below one row. Verified on the Retroid against a live host, in landscape at 468dp: State and Actions share the row, Reset Game Profile is above the fold, the insight cards read to their end, tapping beside the panel returns to the Overview, and Close returns from the studio. --- .../papi/nova/ui/NovaGameDetailComposeTest.kt | 1 + .../com/papi/nova/ui/NovaArtworkStudio.kt | 4 +- .../papi/nova/ui/NovaGameDetailActivity.kt | 1 + .../com/papi/nova/ui/NovaGameDetailContent.kt | 94 +++++---- .../nova/ui/NovaGameDetailDestinations.kt | 179 +++++++++++++++--- app/src/main/res/values/strings.xml | 2 + .../nova/ui/NovaComposeSourceGuardTest.kt | 10 +- 7 files changed, 225 insertions(+), 66 deletions(-) diff --git a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt index 70e0801c..bdf939ae 100644 --- a/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt +++ b/app/src/androidTest/java/com/papi/nova/ui/NovaGameDetailComposeTest.kt @@ -130,6 +130,7 @@ class NovaGameDetailComposeTest { activeSession = null, onResumeSession = {}, onEndSession = {}, + onDismissDestination = {}, ) } } diff --git a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt index ac5005e4..7be75ca9 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt @@ -288,10 +288,12 @@ fun NovaArtworkStudio( choicePreviewLoader: (ImageView, PolarisArtworkChoice) -> Unit, currentArtworkPresentationKey: (String) -> String, currentArtworkLoader: (ImageView, String) -> Unit, + /** True when the studio is the destination rather than a row inside one. */ + initiallyExpanded: Boolean = false, ) { val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current - var expanded by remember(initialQuery) { mutableStateOf(false) } + var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) } var query by remember(initialQuery) { mutableStateOf(initialQuery) } val title = stringResource(R.string.nova_artwork_studio_title) val summary = stringResource(R.string.nova_artwork_studio_summary) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index 5c2c5df0..f4d11343 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -516,6 +516,7 @@ class NovaGameDetailActivity : NovaActivity() { apiClient = apiClient, sourceLabel = currentGame.sourceLabel, onDestination = { next -> destination = next }, + onDismissDestination = { dismissActiveDetailDestination() }, activeSession = activeSession, onResumeSession = { finishWithSessionRequest(RESULT_SESSION_RESUME) }, onEndSession = { finishWithSessionRequest(RESULT_SESSION_END) }, diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 04f27d13..0b285762 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -292,6 +292,7 @@ internal fun NovaGameDetailContent( activeSession: NovaLibraryActiveSessionUiState?, onResumeSession: () -> Unit, onEndSession: () -> Unit, + onDismissDestination: () -> Unit, ) { val verticalScroll = rememberScrollState() val playFocusRequester = remember { FocusRequester() } @@ -333,6 +334,7 @@ internal fun NovaGameDetailContent( optimizationState.profileSummary?.selectedLine.orEmpty() }, scrollState = verticalScroll, + onDismiss = onDismissDestination, ) { val decision = steamDecision if (decision != null) { @@ -381,47 +383,61 @@ internal fun NovaGameDetailContent( optimizationState.profileSummary?.freshnessLine, ).filter { !it.isNullOrBlank() }.joinToString(" · "), scrollState = verticalScroll, + onDismiss = onDismissDestination, ) { - NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state)) - profileOptionsState?.let { - NovaProfilePreferenceSheet( - state = it, - onSelected = onProfilePreferenceSelected, - onDismiss = onDismissProfileOptions - ) - } - SteamLaunchModeCard( - visible = uiState.showSteamLaunchMode, - label = steamLaunchLabel, - modeLabel = steamLaunchModeLabel, - caption = steamLaunchCaption, - warning = uiState.steamLaunchWarning, - onClick = onSteamLaunchMode + NovaGameDetailColumns( + left = { + NovaGameDetailGroupLabel( + stringResource(R.string.nova_game_detail_group_state), + ) + profileOptionsState?.let { + NovaProfilePreferenceSheet( + state = it, + onSelected = onProfilePreferenceSelected, + onDismiss = onDismissProfileOptions + ) + } + SteamLaunchModeCard( + visible = uiState.showSteamLaunchMode, + label = steamLaunchLabel, + modeLabel = steamLaunchModeLabel, + caption = steamLaunchCaption, + warning = uiState.steamLaunchWarning, + onClick = onSteamLaunchMode + ) + steamLaunchOptionsState?.let { state -> + NovaSteamLaunchModeSheet( + state = state, + onSelected = onSteamLaunchModeSelected, + onDismiss = onDismissSteamLaunchModeOptions + ) + } + if (mangoHudEnabled) { + MangoHudPassiveStatus( + label = mangoHudStatusLabel, + caption = mangoHudStatusCaption, + warning = mangoHudWarning + ) + } + }, + right = { + NovaGameDetailGroupLabel( + stringResource(R.string.nova_game_detail_group_actions), + ) + LaunchProfileSummaryActions( + summary = optimizationState.profileSummary, + resetProfileLabel = resetProfileLabel, + resetProfileWorking = resetProfileWorking, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, + ) + }, ) - steamLaunchOptionsState?.let { state -> - NovaSteamLaunchModeSheet( - state = state, - onSelected = onSteamLaunchModeSelected, - onDismiss = onDismissSteamLaunchModeOptions - ) - } - if (mangoHudEnabled) { - MangoHudPassiveStatus( - label = mangoHudStatusLabel, - caption = mangoHudStatusCaption, - warning = mangoHudWarning - ) - } + // Prose, not cards: half a body is not enough to read these without + // wrapping every line, so they keep the full width. + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_insight)) optimizationState.ai?.let { InsightCard(card = it) } optimizationState.stability?.let { InsightCard(card = it) } - NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions)) - LaunchProfileSummaryActions( - summary = optimizationState.profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile, - ) } // The studio opens with a Row of weighted Columns, so it needs the window @@ -430,8 +446,10 @@ internal fun NovaGameDetailContent( eyebrow = stringResource(R.string.nova_artwork_studio_title), headline = uiState.game.name, scrollState = verticalScroll, + onDismiss = onDismissDestination, ) { NovaArtworkStudio( + initiallyExpanded = true, state = artworkState, initialQuery = uiState.game.name, onRefresh = onRefreshArtwork, @@ -1792,7 +1810,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) { NovaDetailPanel( modifier = Modifier .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp), + .padding(top = 10.dp), accent = !card.isWarning, warning = card.isWarning, contentPadding = PaddingValues(12.dp) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index 766749a9..ed20503d 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -2,6 +2,8 @@ package com.papi.nova.ui import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,8 +25,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -57,41 +63,105 @@ internal fun NovaGameDetailPanel( headline: String, readout: String, scrollState: ScrollState, + onDismiss: () -> Unit, content: @Composable () -> Unit, ) { val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current - BoxWithConstraints(modifier = Modifier.fillMaxSize().background(colors.window.copy(alpha = 0.58f))) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(colors.window.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA)) + // The dimmed area beside the panel is the game you came from, so tapping it + // is the same gesture as pressing back. + .novaDismissOnTap(onDismiss) + .testTag("nova-game-detail-scrim"), + ) { // The panel exists so the game stays visible beside what you are changing. In // portrait there is nothing to sit beside, so it takes the window instead of // squeezing a phone-width column inside a phone. val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT + val bodyWidth = maxWidth * widthFraction - NovaGameDetailInset * 2 Column( modifier = Modifier .align(Alignment.CenterEnd) .fillMaxHeight() .fillMaxWidth(widthFraction) - .background(colors.window) + // Translucent, so the game reads underneath and the panel is a layer over + // it rather than another screen. Separation comes from the outside scrim. + .background(colors.window.copy(alpha = NOVA_DETAIL_PANEL_ALPHA)) .background(surfaces.panel) + // Taps inside the panel are not taps outside it. + .novaDismissOnTap {} .windowInsetsPadding(WindowInsets.safeContent) .padding(horizontal = NovaGameDetailInset, vertical = if (shortViewport) 10.dp else 20.dp) .testTag("nova-game-detail-panel"), ) { - NovaGameDetailDestinationHeader(eyebrow, headline, readout, compact = shortViewport) - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(scrollState), - content = { content() }, + NovaGameDetailDestinationHeader( + eyebrow = eyebrow, + headline = headline, + readout = readout, + compact = shortViewport, + onDismiss = onDismiss, ) + CompositionLocalProvider( + LocalNovaDetailWideBody provides (bodyWidth >= NOVA_DETAIL_TWO_COLUMN_MIN), + ) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scrollState), + content = { content() }, + ) + } NovaGameDetailDestinationHints() } } } +/** A tap target that swallows the gesture, with no ripple to imply a button. */ +private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed { + clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ) +} + +/** + * True when the destination body has room for two readable columns. Provided by the + * panel because only the panel knows how much of the window it took. + */ +internal val LocalNovaDetailWideBody = staticCompositionLocalOf { false } + +/** + * Two columns when there is width for them, stacked when there is not. Wide, this stops + * a 500dp body running one narrow column with everything else below the fold. + */ +@Composable +internal fun NovaGameDetailColumns( + left: @Composable () -> Unit, + right: @Composable () -> Unit, +) { + if (LocalNovaDetailWideBody.current) { + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f), content = { left() }) + Column(modifier = Modifier.weight(1f), content = { right() }) + } + } else { + Column(modifier = Modifier.fillMaxWidth()) { + left() + right() + } + } +} + /** * A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a * Row of weighted Columns and cannot fold into a panel. @@ -101,28 +171,43 @@ internal fun NovaGameDetailFullScreen( eyebrow: String, headline: String, scrollState: ScrollState, + onDismiss: () -> Unit, content: @Composable () -> Unit, ) { val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current - Column( - modifier = Modifier - .fillMaxSize() - .background(colors.window) - .background(surfaces.panel) - .windowInsetsPadding(WindowInsets.safeContent) - .padding(horizontal = NovaGameDetailInset, vertical = 20.dp) - .testTag("nova-game-detail-fullscreen"), - ) { - NovaGameDetailDestinationHeader(eyebrow, headline, readout = "", compact = false) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT Column( modifier = Modifier - .weight(1f) - .fillMaxWidth() - .verticalScroll(scrollState), - content = { content() }, - ) - NovaGameDetailDestinationHints() + .fillMaxSize() + // Solid: there is no outside here, so translucency would only print the + // Overview through the studio rather than reveal anything new. + .background(colors.window) + .background(surfaces.panel) + .windowInsetsPadding(WindowInsets.safeContent) + .padding( + horizontal = NovaGameDetailInset, + vertical = if (shortViewport) 10.dp else 20.dp, + ) + .testTag("nova-game-detail-fullscreen"), + ) { + NovaGameDetailDestinationHeader( + eyebrow = eyebrow, + headline = headline, + readout = "", + compact = shortViewport, + onDismiss = onDismiss, + ) + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scrollState), + content = { content() }, + ) + NovaGameDetailDestinationHints() + } } } @@ -151,9 +236,14 @@ private fun NovaGameDetailDestinationHeader( headline: String, readout: String, compact: Boolean = false, + onDismiss: () -> Unit = {}, ) { val colors = LocalNovaComposeColors.current - Column(modifier = Modifier.padding(bottom = if (compact) 6.dp else 14.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(bottom = if (compact) 6.dp else 14.dp), + ) { + Column(modifier = Modifier.weight(1f)) { if (!compact) { Text( text = eyebrow, @@ -184,6 +274,34 @@ private fun NovaGameDetailDestinationHeader( ) } } + // Portrait and the studio have no outside to tap, so the way out is always here. + NovaGameDetailCloseControl(onDismiss) + } +} + +/** The touch equivalent of back, for the destinations that fill the window. */ +@Composable +private fun NovaGameDetailCloseControl(onDismiss: () -> Unit) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(start = 12.dp) + .clip(RoundedCornerShape(NovaGameDetailCornerRadius)) + .background(surfaces.control) + .border(1.dp, colors.divider.copy(alpha = 0.6f), RoundedCornerShape(NovaGameDetailCornerRadius)) + .novaDismissOnTap(onDismiss) + .padding(horizontal = 12.dp, vertical = 7.dp) + .testTag("nova-game-detail-close"), + ) { + Text( + text = stringResource(R.string.nova_game_detail_close), + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + } } /** @@ -357,5 +475,14 @@ private fun NovaSteamChoiceRow( */ private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f +/** Enough of the game stays visible for the panel to read as a layer over it. */ +private const val NOVA_DETAIL_SCRIM_ALPHA = 0.72f + +/** Translucent enough to show artwork, opaque enough to keep body text legible. */ +private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f + +/** Two columns need this much body width before either becomes too narrow to read. */ +private val NOVA_DETAIL_TWO_COLUMN_MIN = 440.dp + /** Below this a phone in landscape has no height to spare for chrome. */ private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f59c49d2..0e98e991 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,6 +269,8 @@ Where it runs State Actions + Insight + Close ✦ POLARIS A B diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index 85ace9e7..d5fd6df2 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -1227,7 +1227,15 @@ class NovaComposeSourceGuardTest { ) val panel = readSource("src/main/java/com/papi/nova/ui/NovaArtworkStudio.kt") - assertTrue("artwork preferences should start collapsed", panel.contains("var expanded by remember(initialQuery) { mutableStateOf(false) }")) + assertTrue( + "artwork preferences should start collapsed wherever the studio is one row among many", + panel.contains("initiallyExpanded: Boolean = false") && + panel.contains("var expanded by remember(initialQuery) { mutableStateOf(initiallyExpanded) }") + ) + assertTrue( + "the destination that is nothing but the studio should open it, not cost a tap and leave the window empty", + content.contains("NovaArtworkStudio(\n initiallyExpanded = true,") + ) assertTrue("artwork header should toggle expansion", panel.contains("clickable { expanded = !expanded }") && panel.contains("if (expanded)")) assertTrue("Studio should show persisted identity and composition beside the live draft", panel.contains("R.string.nova_artwork_current_match") && panel.contains("R.string.nova_artwork_current_composition") && panel.contains("R.string.nova_artwork_live_preview")) assertTrue("Studio should render Poster, Hero, Logo, and Icon composition layers", NovaArtworkKinds.ALL.all { kind -> panel.contains("kind = NovaArtworkKinds.${kind.uppercase()}") }) From 022f506e1ae05259526109901e6a49ec19bcee49 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:27:43 -0400 Subject: [PATCH 08/24] fix(nova): keep the destination scrim neutral across themes Dimming with the window colour is only dimming while the window is dark. Portable Chrome is a light theme, so the same expression painted a white veil over the artwork: the hero washed to flat grey and the identity line beside the panel went nearly illegible, because that text is drawn in on-media colours that assume a darkened image underneath. A scrim is a shadow rather than a surface, so it stays dark whatever the theme is. Checked in landscape on the Retroid against a live host across all six themes. Polaris, OLED, Miami, High Contrast and Material You were already correct and are unchanged by this; Portable Chrome now dims rather than bleaches. --- .../com/papi/nova/ui/NovaGameDetailDestinations.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index ed20503d..d736e105 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag @@ -72,7 +73,7 @@ internal fun NovaGameDetailPanel( BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(colors.window.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA)) + .background(NovaGameDetailScrim.copy(alpha = NOVA_DETAIL_SCRIM_ALPHA)) // The dimmed area beside the panel is the game you came from, so tapping it // is the same gesture as pressing back. .novaDismissOnTap(onDismiss) @@ -475,8 +476,14 @@ private fun NovaSteamChoiceRow( */ private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f +/** + * A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in + * the window colour turned into a white veil under Portable Chrome. + */ +private val NovaGameDetailScrim = Color.Black + /** Enough of the game stays visible for the panel to read as a layer over it. */ -private const val NOVA_DETAIL_SCRIM_ALPHA = 0.72f +private const val NOVA_DETAIL_SCRIM_ALPHA = 0.62f /** Translucent enough to show artwork, opaque enough to keep body text legible. */ private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f From baf0dd448242c0452cbf313897f86fa42af526ea Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:49:39 -0400 Subject: [PATCH 09/24] feat(nova): let launch mode answer where it runs, and give tune the profile On device the destination said its own name three times: the header, a card titled Launch Mode inside it, and a badge repeating what the mode pills already say. It then spent the rest of the panel on AI Preference, the Launch Profile summary and Reset Game Profile, which are Tune's and which Tune now holds under State and Actions. Keeping them here was the clutter this window was built to remove, moved one screen over. What is left is the question the concept named it for: which of the two modes it runs in, why one is preferred, and the way to fuller settings. The intro stops truncating mid-sentence now that it has the room. Removing that duplicate took the only entry to the profile preference picker with it, leaving the picker with no caller at all. The concept leads Tune's State with the profile itself, above MangoHUD and Steam launch mode, and that is where it goes. Pickers also stop rendering inside the State column, where half a body truncated the title and pushed the options past the fold; they span the body, above the groups. Choosing a mode now returns to the Overview, as the concept specifies. Verified on the Retroid against a live host with a virtual display backend available, so this destination could be reached at all for the first time. Known, pre-existing and not addressed here: selecting a launch mode persists but does not refresh the in-memory state, so the Selected pill still shows the previous mode until the window is reopened. --- .../papi/nova/ui/NovaGameDetailActivity.kt | 8 +- .../com/papi/nova/ui/NovaGameDetailContent.kt | 261 +++--------------- .../nova/ui/NovaGameDetailDestinations.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../nova/ui/NovaComposeSourceGuardTest.kt | 27 +- .../papi/nova/ui/NovaLaunchSourceGuardTest.kt | 2 +- 6 files changed, 66 insertions(+), 235 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index f4d11343..7b631a6e 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -539,7 +539,13 @@ class NovaGameDetailActivity : NovaActivity() { profileOptionsState = null } }, - onLaunchModeSelected = ::selectLaunchMode, + onLaunchModeSelected = { mode -> + // The concept: choosing sets the mode and returns to the Overview, + // whose readout reflects it. Staying put left the pill showing the + // previous mode until the window was opened again. + selectLaunchMode(mode) + destination = NovaGameDetailDestination.OVERVIEW + }, onLaunchOptionSelected = { option -> fun launchSelected(mirrorDesktop: Boolean, forcePrivateAfterSteamClose: Boolean = false) { val selectedLaunchOptimization = option.launchOptimization ?: optimizationState.rawOptimization diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 0b285762..3dd4931b 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -343,25 +343,18 @@ internal fun NovaGameDetailContent( onChoice = onSteamChoice, ) } else { - LaunchControlsPanel( + LaunchControls( uiState = uiState, launchIntro = launchIntro, launchModeTitle = launchModeTitle, - recommendedBadge = recommendedBadge, launchOptionsLabel = launchOptionsLabel, - profilePreferenceLabel = profilePreferenceLabel, profileSummary = optimizationState.profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, headlessModeLabel = headlessModeLabel, virtualDisplayModeLabel = virtualDisplayModeLabel, playFocusRequester = playFocusRequester, detailsFocusRequester = detailsFocusRequester, onLaunchOptions = onLaunchOptions, onLaunchModeSelected = onLaunchModeSelected, - onProfilePreference = onProfilePreference, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile ) launchOptionsState?.let { NovaLaunchOptionsSheet( @@ -385,18 +378,38 @@ internal fun NovaGameDetailContent( scrollState = verticalScroll, onDismiss = onDismissDestination, ) { + // A picker is what you are doing with the destination while it is + // open, not a row inside one of its groups, so it spans the body, + // above the groups rather than below the fold they push it past. + profileOptionsState?.let { + NovaProfilePreferenceSheet( + state = it, + onSelected = onProfilePreferenceSelected, + onDismiss = onDismissProfileOptions + ) + } + steamLaunchOptionsState?.let { state -> + NovaSteamLaunchModeSheet( + state = state, + onSelected = onSteamLaunchModeSelected, + onDismiss = onDismissSteamLaunchModeOptions + ) + } + NovaGameDetailColumns( left = { NovaGameDetailGroupLabel( stringResource(R.string.nova_game_detail_group_state), ) - profileOptionsState?.let { - NovaProfilePreferenceSheet( - state = it, - onSelected = onProfilePreferenceSelected, - onDismiss = onDismissProfileOptions - ) - } + // The concept leads State with the profile itself. It is also the + // only way into the preference picker, now that launch mode no + // longer carries a second copy of Tune's controls. + NovaSteamChoiceRow( + label = profilePreferenceLabel, + caption = stringResource(R.string.nova_game_detail_profile_caption), + enabled = true, + onClick = onProfilePreference, + ) SteamLaunchModeCard( visible = uiState.showSteamLaunchMode, label = steamLaunchLabel, @@ -405,13 +418,6 @@ internal fun NovaGameDetailContent( warning = uiState.steamLaunchWarning, onClick = onSteamLaunchMode ) - steamLaunchOptionsState?.let { state -> - NovaSteamLaunchModeSheet( - state = state, - onSelected = onSteamLaunchModeSelected, - onDismiss = onDismissSteamLaunchModeOptions - ) - } if (mangoHudEnabled) { MangoHudPassiveStatus( label = mangoHudStatusLabel, @@ -1008,58 +1014,6 @@ private fun NovaGameDetailIdentity( } } -@Composable -private fun LaunchControlsPanel( - uiState: NovaGameDetailUiState, - launchIntro: String, - launchModeTitle: String, - recommendedBadge: String, - launchOptionsLabel: String, - profilePreferenceLabel: String, - profileSummary: NovaLaunchProfileSummary?, - resetProfileLabel: String, - resetProfileWorking: Boolean, - headlessModeLabel: String, - virtualDisplayModeLabel: String, - playFocusRequester: FocusRequester, - detailsFocusRequester: FocusRequester, - onLaunchOptions: () -> Unit, - onLaunchModeSelected: (String) -> Unit, - onProfilePreference: () -> Unit, - onRetryHighFps: () -> Unit, - onResetProfile: () -> Unit -) { - NovaDetailPanel( - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp), - contentDescription = "Launch controls", - accent = true, - contentPadding = PaddingValues(12.dp) - ) { - LaunchControls( - uiState = uiState, - launchIntro = launchIntro, - launchModeTitle = launchModeTitle, - recommendedBadge = recommendedBadge, - launchOptionsLabel = launchOptionsLabel, - profilePreferenceLabel = profilePreferenceLabel, - profileSummary = profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, - headlessModeLabel = headlessModeLabel, - virtualDisplayModeLabel = virtualDisplayModeLabel, - playFocusRequester = playFocusRequester, - detailsFocusRequester = detailsFocusRequester, - onLaunchOptions = onLaunchOptions, - onLaunchModeSelected = onLaunchModeSelected, - onProfilePreference = onProfilePreference, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile - ) - } -} - @Composable private fun MetadataBadges(game: PolarisGame) { val horizontalScroll = rememberScrollState() @@ -1102,59 +1056,24 @@ private fun LaunchControls( uiState: NovaGameDetailUiState, launchIntro: String, launchModeTitle: String, - recommendedBadge: String, launchOptionsLabel: String, - profilePreferenceLabel: String, profileSummary: NovaLaunchProfileSummary?, - resetProfileLabel: String, - resetProfileWorking: Boolean, headlessModeLabel: String, virtualDisplayModeLabel: String, playFocusRequester: FocusRequester, detailsFocusRequester: FocusRequester, onLaunchOptions: () -> Unit, onLaunchModeSelected: (String) -> Unit, - onProfilePreference: () -> Unit, - onRetryHighFps: () -> Unit, - onResetProfile: () -> Unit ) { val colors = LocalNovaComposeColors.current Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = launchModeTitle, - modifier = Modifier.weight(1f), - color = colors.textPrimary, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (uiState.showRecommendedModeBadge) { - Spacer(modifier = Modifier.width(8.dp)) - NovaBadge( - text = recommendedBadge, - color = colors.onAccent, - backgroundColor = colors.accent.copy(alpha = 0.86f), - borderColor = colors.accent, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 5.dp) - ) - } - } - Text( text = launchIntro, - modifier = Modifier.padding(top = 6.dp), color = if (uiState.virtualDisplayUnavailable) colors.warning else colors.textSecondary, fontSize = 11.sp, - lineHeight = 13.sp, - maxLines = 2, + lineHeight = 14.sp, + maxLines = 4, overflow = TextOverflow.Ellipsis ) @@ -1226,56 +1145,20 @@ private fun LaunchControls( } } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 9.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (uiState.showLaunchOptionsButton) { - NovaActionButton( - text = launchOptionsLabel, - onClick = onLaunchOptions, - modifier = Modifier.weight(1f), - contentDescription = launchOptionsLabel, - minHeight = 42.dp, - cornerRadius = 10.dp, - fontSize = 12.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) - ) - } + if (uiState.showLaunchOptionsButton) { NovaActionButton( - text = profilePreferenceLabel, - onClick = onProfilePreference, - modifier = if (uiState.showLaunchOptionsButton) Modifier.weight(1f) else Modifier.fillMaxWidth(), - contentDescription = profilePreferenceLabel, + text = launchOptionsLabel, + onClick = onLaunchOptions, + modifier = Modifier + .fillMaxWidth() + .padding(top = 9.dp), + contentDescription = launchOptionsLabel, minHeight = 42.dp, cornerRadius = 10.dp, fontSize = 12.sp, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) ) } - - profileSummary?.let { - LaunchProfileSummaryInline( - summary = it, - onRetryHighFps = onRetryHighFps - ) - } - - NovaActionButton( - text = resetProfileLabel, - onClick = onResetProfile, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - enabled = !resetProfileWorking, - contentDescription = resetProfileLabel, - minHeight = 36.dp, - cornerRadius = 10.dp, - fontSize = 11.sp, - contentPadding = PaddingValues(horizontal = 9.dp, vertical = 7.dp) - ) } } @@ -1375,76 +1258,6 @@ internal fun LaunchProfilePrimaryNotice( } } -@Composable -private fun LaunchProfileSummaryInline( - summary: NovaLaunchProfileSummary, - onRetryHighFps: () -> Unit -) { - val colors = LocalNovaComposeColors.current - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp) - .semantics { contentDescription = "Launch profile summary" } - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .heightIn(min = 1.dp, max = 1.dp) - .background(colors.divider.copy(alpha = 0.55f)) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Launch Profile", - color = colors.accent, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - ProfileSummaryText(summary.selectedLine, topPadding = 4) - ProfileSummaryText(summary.requestedLine) - ProfileSummaryText(summary.limitingLine) - ProfileSummaryText(summary.reasonLine) - } - } - - if (summary.historyLines.isNotEmpty()) { - Text( - text = summary.historyLines.first(), - modifier = Modifier.padding(top = 4.dp), - color = colors.textMuted, - fontSize = 10.sp, - lineHeight = 13.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } else { - ProfileSummaryText(summary.freshnessLine) - } - - if (summary.showRetryHighFps) { - NovaActionButton( - text = summary.retryHighFpsLabel, - onClick = onRetryHighFps, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp), - contentDescription = summary.retryHighFpsLabel, - minHeight = 36.dp, - cornerRadius = 8.dp, - fontSize = 11.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 7.dp) - ) - } - } -} - @Composable private fun LaunchModeChoicePill( label: String, diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index d736e105..fc55eb37 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -437,7 +437,7 @@ internal fun NovaDesktopSteamLaunchDecisionRows( } @Composable -private fun NovaSteamChoiceRow( +internal fun NovaSteamChoiceRow( label: String, caption: String, enabled: Boolean, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e98e991..3a2a9b80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,7 @@ Actions Insight Close + How Polaris picks quality for this game ✦ POLARIS A B diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index d5fd6df2..316320c7 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -1036,7 +1036,7 @@ class NovaComposeSourceGuardTest { val detail = readNovaGameDetail() val detailsPanel = detail.section( "private fun GameDetailsPanel(", - "@Composable\nprivate fun LaunchControlsPanel(" + "@Composable\nprivate fun LaunchControls(" ) val launchFooter = detail.section( "internal fun NovaGameDetailLaunchFooter(", @@ -1117,7 +1117,7 @@ class NovaComposeSourceGuardTest { val detail = readNovaGameDetail() val launchControls = detail.section( "private fun LaunchControls(", - "@Composable\nprivate fun LaunchProfileSummaryInline(" + "@Composable\ninternal fun LaunchProfilePrimaryNotice(" ) assertTrue( @@ -1134,10 +1134,21 @@ class NovaComposeSourceGuardTest { launchControls.indexOf("text = launchOptionsLabel") > launchControls.indexOf("LaunchModeChoicePill(") ) assertTrue( - "non-duplicative tuning should remain available separately from launch mode selection", - launchControls.contains("text = profilePreferenceLabel") && - launchControls.indexOf("text = profilePreferenceLabel") > launchControls.indexOf("LaunchModeChoicePill(") && - launchControls.split("LaunchProfileSummaryInline(").size == 2 + "launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's", + !launchControls.contains("profilePreferenceLabel") && + !launchControls.contains("LaunchProfileSummaryInline(") && + !launchControls.contains("resetProfileLabel") + ) + assertTrue( + "Tune should hold the profile controls launch mode gave up, and stay the way into the preference picker", + detail.contains("onClick = onProfilePreference,") && + detail.contains("LaunchProfileSummaryActions(") && + detail.contains("R.string.nova_game_detail_group_actions") + ) + assertTrue( + "launch mode should name itself once: the destination header already does, and the pills say which is recommended", + !launchControls.contains("text = launchModeTitle") && + !launchControls.contains("text = recommendedBadge") ) } @@ -1157,7 +1168,7 @@ class NovaComposeSourceGuardTest { "when MangoHUD is already enabled, the drawer should show only a passive status after launch controls", sheetContent.contains("if (mangoHudEnabled) {") && sheetContent.contains("MangoHudPassiveStatus(") && - sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControlsPanel(") + sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControls(") ) } @@ -1166,7 +1177,7 @@ class NovaComposeSourceGuardTest { val source = readNovaGameDetail() val detailsPanel = source.section( "private fun GameDetailsPanel(", - "@Composable\nprivate fun LaunchControlsPanel(" + "@Composable\nprivate fun LaunchControls(" ) assertTrue( diff --git a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt index 31fb260c..afd3858a 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaLaunchSourceGuardTest.kt @@ -55,7 +55,7 @@ class NovaLaunchSourceGuardTest { val nvHttp = readSource("src/main/java/com/papi/nova/nvstream/http/NvHTTP.kt") val decisionRows = detail.section( "internal fun NovaDesktopSteamLaunchDecisionRows(", - "private fun NovaSteamChoiceRow(" + "internal fun NovaSteamChoiceRow(" ) assertTrue( From f91c871439bd8bdbb90b3504b2b1a726e6e953ba Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:06:34 -0400 Subject: [PATCH 10/24] fix(nova): give the drill-in destinations somewhere for focus to land The only requestFocus in the whole window was the Overview's play button, so opening a destination left focus with no anchor: nothing took it on entry and the d-pad moved nothing at all, on a handheld whose primary input is a d-pad. The body is now a focus group that asks for focus once it has been laid out, which is the idiom the library already uses for its own drawer. Once focus had somewhere to land it could also leave. Pressing left walked out of the panel and onto the Overview's Launch Mode button, dimmed behind the scrim, off the panel and one press from acting. While a destination is open the Overview is scenery, so it stops taking focus for as long as that is true. Verified on the Retroid in landscape: Tune opens with the profile row focused, down moves within a group, right crosses to the next one, and four presses of left now stay inside the panel instead of falling through to the screen behind it. --- .../com/papi/nova/ui/NovaGameDetailContent.kt | 8 ++++++ .../nova/ui/NovaGameDetailDestinations.kt | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 3dd4931b..ffbf47b4 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -320,6 +321,13 @@ internal fun NovaGameDetailContent( activeSession = activeSession, onResumeSession = onResumeSession, onEndSession = onEndSession, + // While a destination is open the Overview is scenery. Without this, a d-pad + // press walks out of the panel onto a control dimmed behind the scrim. + modifier = if (destination == NovaGameDetailDestination.OVERVIEW) { + Modifier + } else { + Modifier.focusGroup().focusProperties { canFocus = false } + }, ) when (destination) { diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index fc55eb37..2cb5be7e 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -3,6 +3,7 @@ package com.papi.nova.ui import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -26,10 +27,13 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.composed import androidx.compose.ui.draw.clip @@ -40,6 +44,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import com.papi.nova.R import com.papi.nova.ui.compose.LocalNovaComposeColors import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces @@ -114,6 +119,7 @@ internal fun NovaGameDetailPanel( modifier = Modifier .weight(1f) .fillMaxWidth() + .novaHoldsFirstFocus() .verticalScroll(scrollState), content = { content() }, ) @@ -123,6 +129,21 @@ internal fun NovaGameDetailPanel( } } +/** + * Takes focus for its first focusable child once it has been laid out. Without this a + * destination opens with focus left behind on the Overview, so the d-pad has nothing to + * move. The delay is the same one the library uses: the request only lands after layout. + */ +@Composable +private fun Modifier.novaHoldsFirstFocus(): Modifier { + val requester = remember { FocusRequester() } + LaunchedEffect(Unit) { + delay(NOVA_DETAIL_FOCUS_SETTLE_MS) + runCatching { requester.requestFocus() } + } + return focusRequester(requester).focusGroup() +} + /** A tap target that swallows the gesture, with no ripple to imply a button. */ private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed { clickable( @@ -204,6 +225,7 @@ internal fun NovaGameDetailFullScreen( modifier = Modifier .weight(1f) .fillMaxWidth() + .novaHoldsFirstFocus() .verticalScroll(scrollState), content = { content() }, ) @@ -491,5 +513,8 @@ private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f /** Two columns need this much body width before either becomes too narrow to read. */ private val NOVA_DETAIL_TWO_COLUMN_MIN = 440.dp +/** Long enough for the body to be laid out, so the focus request has a target. */ +private const val NOVA_DETAIL_FOCUS_SETTLE_MS = 75L + /** Below this a phone in landscape has no height to spare for chrome. */ private val NOVA_DETAIL_SHORT_VIEWPORT = 500.dp From fbdbe1db071f58f141f1d15550967e85f7fa7ed9 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:20:30 -0400 Subject: [PATCH 11/24] feat(nova): rebuild the drawer interiors as full-bleed rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawers read as a stack of rounded bubbles: cards floating inside a panel, each large and mostly empty, with small text inside them. Wide and barren at the same time. The approved concept never asked for cards. It specified 48dp rows in a 438dp lane, label on the left and value on the right, and focus shown as an inset accent bar that does not move the row. So the card goes. Rows run edge to edge, separated by a hairline rather than by air, with the value set in the tabular figures the Overview readout already uses. The two-column split goes with it: that was mine rather than the concept's, and uneven column heights were what left the holes. Tune is one lane again, State then Actions then Insight, and the insight stops being another box. The rest of the drawn tokens follow: the lane is 53% rather than 60%, the headline is 27sp rather than 22, group labels are 8sp, and the scrim is back to the 58% it was specified at. One trap worth recording. Replacing the card cost the rows their focus, because Modifier.clickable alone did not register them as focus targets and the d-pad had nothing to reach — six presses moved nothing and scrolled nothing. Every other focusable in this app pairs clickable with an explicit focusable(), and so does this one now. Verified on the Retroid in landscape: rows take focus on entry, down moves between them, and the focused row shows the bar and tint without moving. --- .../com/papi/nova/ui/NovaGameDetailContent.kt | 159 ++++++----------- .../nova/ui/NovaGameDetailDestinations.kt | 166 +++++++++++------- app/src/main/res/values/strings.xml | 1 + 3 files changed, 152 insertions(+), 174 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index ffbf47b4..08e05bdb 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -404,51 +404,42 @@ internal fun NovaGameDetailContent( ) } - NovaGameDetailColumns( - left = { - NovaGameDetailGroupLabel( - stringResource(R.string.nova_game_detail_group_state), - ) - // The concept leads State with the profile itself. It is also the - // only way into the preference picker, now that launch mode no - // longer carries a second copy of Tune's controls. - NovaSteamChoiceRow( - label = profilePreferenceLabel, - caption = stringResource(R.string.nova_game_detail_profile_caption), - enabled = true, - onClick = onProfilePreference, - ) - SteamLaunchModeCard( - visible = uiState.showSteamLaunchMode, - label = steamLaunchLabel, - modeLabel = steamLaunchModeLabel, - caption = steamLaunchCaption, - warning = uiState.steamLaunchWarning, - onClick = onSteamLaunchMode - ) - if (mangoHudEnabled) { - MangoHudPassiveStatus( - label = mangoHudStatusLabel, - caption = mangoHudStatusCaption, - warning = mangoHudWarning - ) - } - }, - right = { - NovaGameDetailGroupLabel( - stringResource(R.string.nova_game_detail_group_actions), - ) - LaunchProfileSummaryActions( - summary = optimizationState.profileSummary, - resetProfileLabel = resetProfileLabel, - resetProfileWorking = resetProfileWorking, - onRetryHighFps = onRetryHighFps, - onResetProfile = onResetProfile, - ) - }, + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_state)) + // The concept leads State with the profile itself, and it is the only way + // into the preference picker now that launch mode no longer carries a + // second copy of Tune's controls. + NovaSteamChoiceRow( + label = stringResource(R.string.nova_game_detail_profile_label), + caption = stringResource(R.string.nova_game_detail_profile_caption), + enabled = true, + onClick = onProfilePreference, + value = stringResource( + AutoQualityProfilePreferences.shortLabelRes(uiState.profilePreference), + ), + ) + SteamLaunchModeCard( + visible = uiState.showSteamLaunchMode, + label = steamLaunchLabel, + modeLabel = steamLaunchModeLabel, + caption = steamLaunchCaption, + warning = uiState.steamLaunchWarning, + onClick = onSteamLaunchMode + ) + if (mangoHudEnabled) { + MangoHudPassiveStatus( + label = mangoHudStatusLabel, + caption = mangoHudStatusCaption, + warning = mangoHudWarning + ) + } + NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_actions)) + LaunchProfileSummaryActions( + summary = optimizationState.profileSummary, + resetProfileLabel = resetProfileLabel, + resetProfileWorking = resetProfileWorking, + onRetryHighFps = onRetryHighFps, + onResetProfile = onResetProfile, ) - // Prose, not cards: half a body is not enough to read these without - // wrapping every line, so they keep the full width. NovaGameDetailGroupLabel(stringResource(R.string.nova_game_detail_group_insight)) optimizationState.ai?.let { InsightCard(card = it) } optimizationState.stability?.let { InsightCard(card = it) } @@ -1553,40 +1544,13 @@ private fun SteamLaunchModeCard( ) { if (!visible) return - val colors = LocalNovaComposeColors.current - NovaFocusableCard( - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp) - .heightIn(min = 58.dp), + NovaSteamChoiceRow( + label = label, + caption = caption, + enabled = true, onClick = onClick, - contentDescription = "$label. $modeLabel. $caption", - contentPadding = PaddingValues(start = 12.dp, top = 9.dp, end = 12.dp, bottom = 9.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = label, - color = colors.textPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold - ) - Text( - text = caption, - modifier = Modifier.padding(top = 2.dp), - color = if (warning) colors.warning else colors.textSecondary, - fontSize = 9.sp, - lineHeight = 12.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis - ) - } - NovaBadge(text = modeLabel, color = if (warning) colors.warning else colors.textSecondary) - } - } + value = modeLabel, + ) } @Composable @@ -1595,46 +1559,21 @@ private fun MangoHudPassiveStatus( caption: String, warning: Boolean ) { - val colors = LocalNovaComposeColors.current - val surfaces = LocalNovaLibrarySurfaces.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 8.dp) - .semantics { contentDescription = "$label. $caption" }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - NovaBadge( - text = label, - color = if (warning) colors.warning else colors.textSecondary, - backgroundColor = surfaces.control.copy(alpha = 0.56f * LocalNovaMenuOpacityScale.current), - borderColor = if (warning) colors.warning.copy(alpha = 0.44f) else surfaces.tileBorder, - fontSize = 10.sp, - contentPadding = PaddingValues(horizontal = 9.dp, vertical = 4.dp) - ) - Text( - text = caption, - modifier = Modifier.weight(1f), - color = colors.textMuted, - fontSize = 10.sp, - lineHeight = 13.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } + // A readout, not an action: same row, no chevron to imply otherwise. + NovaSteamChoiceRow( + label = label, + caption = caption, + enabled = !warning, + ) } @Composable private fun InsightCard(card: NovaGameDetailInsightCard) { val colors = LocalNovaComposeColors.current - NovaDetailPanel( + Box( modifier = Modifier .fillMaxWidth() - .padding(top = 10.dp), - accent = !card.isWarning, - warning = card.isWarning, - contentPadding = PaddingValues(12.dp) + .padding(top = 12.dp, bottom = 2.dp), ) { Column { Text( diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index 2cb5be7e..962e92b7 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.width @@ -24,21 +26,32 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -89,7 +102,6 @@ internal fun NovaGameDetailPanel( // squeezing a phone-width column inside a phone. val widthFraction = if (maxHeight > maxWidth) 1f else NOVA_DETAIL_PANEL_WIDTH_FRACTION val shortViewport = maxHeight < NOVA_DETAIL_SHORT_VIEWPORT - val bodyWidth = maxWidth * widthFraction - NovaGameDetailInset * 2 Column( modifier = Modifier .align(Alignment.CenterEnd) @@ -112,18 +124,14 @@ internal fun NovaGameDetailPanel( compact = shortViewport, onDismiss = onDismiss, ) - CompositionLocalProvider( - LocalNovaDetailWideBody provides (bodyWidth >= NOVA_DETAIL_TWO_COLUMN_MIN), - ) { - Column( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .novaHoldsFirstFocus() - .verticalScroll(scrollState), - content = { content() }, - ) - } + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .novaHoldsFirstFocus() + .verticalScroll(scrollState), + content = { content() }, + ) NovaGameDetailDestinationHints() } } @@ -153,37 +161,6 @@ private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = compose ) } -/** - * True when the destination body has room for two readable columns. Provided by the - * panel because only the panel knows how much of the window it took. - */ -internal val LocalNovaDetailWideBody = staticCompositionLocalOf { false } - -/** - * Two columns when there is width for them, stacked when there is not. Wide, this stops - * a 500dp body running one narrow column with everything else below the fold. - */ -@Composable -internal fun NovaGameDetailColumns( - left: @Composable () -> Unit, - right: @Composable () -> Unit, -) { - if (LocalNovaDetailWideBody.current) { - Row( - horizontalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.weight(1f), content = { left() }) - Column(modifier = Modifier.weight(1f), content = { right() }) - } - } else { - Column(modifier = Modifier.fillMaxWidth()) { - left() - right() - } - } -} - /** * A drill-in that needs the window. Used by Artwork, whose studio lays itself out as a * Row of weighted Columns and cannot fold into a panel. @@ -279,7 +256,7 @@ private fun NovaGameDetailDestinationHeader( Text( text = if (compact) "$eyebrow · $headline" else headline, color = colors.textPrimary, - fontSize = if (compact) 15.sp else 22.sp, + fontSize = if (compact) 17.sp else 27.sp, fontWeight = FontWeight.Bold, maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, @@ -343,7 +320,7 @@ internal fun NovaGameDetailGroupLabel(text: String) { Text( text = text.uppercase(), color = colors.textMuted, - fontSize = 10.sp, + fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.22.em, ) @@ -458,26 +435,65 @@ internal fun NovaDesktopSteamLaunchDecisionRows( } } +/** + * One row of a drawer. Full bleed rather than a card: the panel already supplies the + * inset, so a rounded box inside it only adds air. The value sits at the right in + * tabular figures, and focus is an inset bar and a tint, so the row never moves. + */ @Composable internal fun NovaSteamChoiceRow( label: String, caption: String, enabled: Boolean, - onClick: () -> Unit, + onClick: (() -> Unit)? = null, + value: String = "", ) { val colors = LocalNovaComposeColors.current - NovaFocusableCard( - onClick = onClick, - enabled = enabled, - contentDescription = label, - modifier = Modifier.fillMaxWidth(), + var focused by remember { mutableStateOf(false) } + val actionable = onClick != null && enabled + val accentBar = colors.accent + val hairline = colors.divider.copy(alpha = 0.45f) + val barWidth = NOVA_DETAIL_ROW_FOCUS_BAR + val tint = colors.accent.copy(alpha = 0.16f) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = NOVA_DETAIL_ROW_MIN_HEIGHT) + .onFocusChanged { focused = it.isFocused || it.hasFocus } + .then( + if (actionable) { + Modifier.clickable(role = Role.Button) { onClick?.invoke() } + } else { + Modifier + } + ) + // Explicit, like every other focusable in the app: clickable alone did not + // register the row as a focus target and the d-pad had nothing to reach. + .focusable(enabled = actionable) + .background(if (focused && actionable) tint else Color.Transparent) + .drawBehind { + if (focused && actionable) { + drawRect(color = accentBar, size = Size(barWidth.toPx(), size.height)) + } + drawRect( + color = hairline, + topLeft = Offset(0f, size.height - 1f), + size = Size(size.width, 1f), + ) + } + .padding(start = 12.dp, end = 2.dp, top = 9.dp, bottom = 9.dp) + .semantics { contentDescription = if (value.isBlank()) label else "$label. $value" }, ) { - Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + Column(modifier = Modifier.weight(1f)) { Text( text = label, color = if (enabled) colors.textPrimary else colors.textMuted, - fontSize = 13.sp, + fontSize = 15.sp, fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) if (caption.isNotBlank()) { Text( @@ -485,18 +501,43 @@ internal fun NovaSteamChoiceRow( color = colors.textMuted, fontSize = 11.sp, lineHeight = 14.sp, - modifier = Modifier.padding(top = 3.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), ) } } + if (value.isNotBlank()) { + Text( + text = value, + color = if (enabled) colors.textSecondary else colors.textMuted, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + // a value read against other values, so the digits line up + style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"), + maxLines = 1, + modifier = Modifier.padding(start = 12.dp), + ) + } + if (actionable) { + Text( + text = "\u203a", + color = colors.textMuted, + fontSize = 17.sp, + modifier = Modifier.padding(start = 10.dp, end = 4.dp), + ) + } } } -/** - * Wide enough for the insight cards, which carry a single-line profile badge that - * truncated at 53%, and still narrow enough to keep the game present beside it. - */ -private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f +/** 438dp of an 832dp landscape shell, as drawn. */ +private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.53f + +/** Every row is at least a full action's worth of height. */ +private val NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp + +/** The focused row grows a bar at its edge instead of a border that moves it. */ +private val NOVA_DETAIL_ROW_FOCUS_BAR = 3.dp /** * A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in @@ -505,14 +546,11 @@ private const val NOVA_DETAIL_PANEL_WIDTH_FRACTION = 0.60f private val NovaGameDetailScrim = Color.Black /** Enough of the game stays visible for the panel to read as a layer over it. */ -private const val NOVA_DETAIL_SCRIM_ALPHA = 0.62f +private const val NOVA_DETAIL_SCRIM_ALPHA = 0.58f /** Translucent enough to show artwork, opaque enough to keep body text legible. */ private const val NOVA_DETAIL_PANEL_ALPHA = 0.80f -/** Two columns need this much body width before either becomes too narrow to read. */ -private val NOVA_DETAIL_TWO_COLUMN_MIN = 440.dp - /** Long enough for the body to be laid out, so the focus request has a target. */ private const val NOVA_DETAIL_FOCUS_SETTLE_MS = 75L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a2a9b80..f1ed2f46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,7 @@ Actions Insight Close + Optimization profile How Polaris picks quality for this game ✦ POLARIS A From e89743a05c13a83cfc4118d87c358f59bfd25fbd Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:26:06 -0400 Subject: [PATCH 12/24] feat(nova): make launch mode's choices rows like the rest of the drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tune became rows but launch mode still offered its two modes as rounded pills side by side, with its settings as another pill underneath — the same bubbles, one screen over. The concept draws these as a list: the mode, why you would pick it, and where it stands on the right. So they are the same row as everything else now, carrying Selected, Recommended, Available or Unavailable as their value, and the captions say what each mode actually does rather than leaving the label to carry it alone. The choice pill has no callers left and goes. Three guards used it as a section boundary or pinned its 52dp height; they move to the composable that is really there and to the shared 48dp row token, which is what enforces that height now. The heads-up banner stays a banner, as drawn. Verified on the Retroid in landscape: both modes and the settings row read as one list, focus lands on the selected mode and moves between them with the accent bar. --- .../com/papi/nova/ui/NovaGameDetailContent.kt | 127 +++++------------- app/src/main/res/values/strings.xml | 3 + .../nova/ui/NovaComposeSourceGuardTest.kt | 20 ++- 3 files changed, 46 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 08e05bdb..6ac733bf 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -1106,56 +1106,45 @@ private fun LaunchControls( } if (uiState.showLaunchOptionsButton || uiState.showVirtualUnavailableHint) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - LaunchModeChoicePill( - label = headlessModeLabel, - status = when { - uiState.playMode == "headless" -> "Selected" - uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended" - uiState.headlessAllowed -> "Available" - else -> "Unavailable" - }, - recommended = uiState.recommendedMode == "headless" && uiState.headlessAllowed, - selected = uiState.playMode == "headless", - unavailable = !uiState.headlessAllowed, - onClick = { onLaunchModeSelected("headless") }, - modifier = Modifier.weight(1f) - ) - LaunchModeChoicePill( - label = virtualDisplayModeLabel, - status = when { - uiState.virtualDisplayUnavailable -> "Unavailable" - uiState.playMode == "virtual_display" -> "Selected" - uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended" - uiState.virtualDisplayAllowed -> "Available" - else -> "Unavailable" - }, - recommended = uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed, - selected = uiState.playMode == "virtual_display", - unavailable = uiState.virtualDisplayUnavailable || !uiState.virtualDisplayAllowed, - onClick = { onLaunchModeSelected("virtual_display") }, - modifier = Modifier.weight(1f) - ) - } + // The two modes are a list of choices, so they read as rows like the rest of + // the drawer, each carrying its standing as the value. + NovaSteamChoiceRow( + label = headlessModeLabel, + caption = stringResource(R.string.nova_game_detail_headless_caption), + enabled = uiState.headlessAllowed, + onClick = { onLaunchModeSelected("headless") }, + value = when { + uiState.playMode == "headless" -> "Selected" + uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended" + uiState.headlessAllowed -> "Available" + else -> "Unavailable" + }, + ) + NovaSteamChoiceRow( + label = virtualDisplayModeLabel, + caption = if (uiState.showVirtualUnavailableHint) { + uiState.virtualDisplayUnavailableReason + } else { + stringResource(R.string.nova_game_detail_virtual_caption) + }, + enabled = uiState.virtualDisplayAllowed && !uiState.virtualDisplayUnavailable, + onClick = { onLaunchModeSelected("virtual_display") }, + value = when { + uiState.virtualDisplayUnavailable -> "Unavailable" + uiState.playMode == "virtual_display" -> "Selected" + uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended" + uiState.virtualDisplayAllowed -> "Available" + else -> "Unavailable" + }, + ) } if (uiState.showLaunchOptionsButton) { - NovaActionButton( - text = launchOptionsLabel, + NovaSteamChoiceRow( + label = launchOptionsLabel, + caption = stringResource(R.string.nova_game_detail_more_settings_caption), + enabled = true, onClick = onLaunchOptions, - modifier = Modifier - .fillMaxWidth() - .padding(top = 9.dp), - contentDescription = launchOptionsLabel, - minHeight = 42.dp, - cornerRadius = 10.dp, - fontSize = 12.sp, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) ) } } @@ -1257,52 +1246,6 @@ internal fun LaunchProfilePrimaryNotice( } } -@Composable -private fun LaunchModeChoicePill( - label: String, - status: String, - recommended: Boolean, - selected: Boolean, - unavailable: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val colors = LocalNovaComposeColors.current - val statusColor = when { - unavailable -> colors.warning - selected || recommended -> colors.accent - else -> colors.textMuted - } - - NovaFocusableCard( - modifier = modifier.heightIn(min = 52.dp), - onClick = onClick, - enabled = !unavailable, - contentDescription = "$label. $status", - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp) - ) { - Column { - Text( - text = label, - color = colors.textPrimary, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = status, - modifier = Modifier.padding(top = 3.dp), - color = statusColor, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } -} - @Composable private fun ProfileSummaryText(text: String, topPadding: Int = 3) { if (text.isBlank()) return diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1ed2f46..77be5501 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,9 @@ Actions Insight Close + Streams without touching the physical desktop + A dedicated display on the host, at your resolution + Resolution, bitrate and codec for this launch Optimization profile How Polaris picks quality for this game ✦ POLARIS diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index 316320c7..3124a629 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -1042,11 +1042,6 @@ class NovaComposeSourceGuardTest { "internal fun NovaGameDetailLaunchFooter(", "@Composable\nprivate fun NovaDetailPanel(" ) - val launchModePill = detail.section( - "private fun LaunchModeChoicePill(", - "@Composable\nprivate fun ProfileSummaryText(" - ) - assertTrue( "Retroid landscape first paint should keep either Hero or Poster identity inside the compact launch header ceiling", detailsPanel.contains(".heightIn(min = 136.dp)") && @@ -1066,7 +1061,7 @@ class NovaComposeSourceGuardTest { assertTrue( "pinned primary launch and mode choice controls should stay compact enough for Retroid landscape", launchFooter.contains("minHeight = 48.dp") && - launchModePill.contains("modifier = modifier.heightIn(min = 52.dp)") + detail.contains("NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp") ) } @@ -1121,17 +1116,18 @@ class NovaComposeSourceGuardTest { ) assertTrue( - "Headless/Virtual should be directly selectable from the detail sheet before launching", - launchControls.contains("LaunchModeChoicePill(") && + "Headless/Virtual should be directly selectable from the destination, as rows carrying their standing", + launchControls.contains("NovaSteamChoiceRow(") && launchControls.contains("onClick = { onLaunchModeSelected(\"headless\") }") && - launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }") + launchControls.contains("onClick = { onLaunchModeSelected(\"virtual_display\") }") && + !launchControls.contains("LaunchModeChoicePill(") ) assertTrue( "Launch Options should remain a secondary path after inline Headless/Virtual choices", detail.contains("private fun showLaunchOptions(") && detail.contains("onLaunchOptions = {") && - launchControls.contains("text = launchOptionsLabel") && - launchControls.indexOf("text = launchOptionsLabel") > launchControls.indexOf("LaunchModeChoicePill(") + launchControls.contains("label = launchOptionsLabel") && + launchControls.indexOf("label = launchOptionsLabel") > launchControls.indexOf("label = headlessModeLabel") ) assertTrue( "launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's", @@ -2707,7 +2703,7 @@ class NovaComposeSourceGuardTest { fun gameDetailLaunchOptionsUseActionableModeState() { val launchControls = readNovaGameDetail().section( "private fun LaunchControls(", - "@Composable\nprivate fun LaunchModeChoicePill(" + "@Composable\ninternal fun LaunchProfilePrimaryNotice(" ) assertTrue(launchControls.contains("uiState.showLaunchOptionsButton")) From e7f23fb0432c988614ea0a83c7d41173b20cdd9b Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:40:50 -0400 Subject: [PATCH 13/24] fix(nova): make the row focus and the heads-up badge survive a light theme Two versions of one mistake. A fixed alpha is not a fixed strength: the accent is light against a dark surface and dark against a light one, so the focus tint that reads as a whisper under Polaris read as an inverted block under Portable Chrome. Measured on device, the focused row there shifted 39 points per channel against 7 to 22 everywhere else. Scaling the alpha by polarity fixes it, but the polarity has to be read from something that tracks the surface. The first attempt asked colors.window and never fired, because under Portable Chrome the panel takes its lightness from surfaces.panel layered over the window, so the window is the wrong ground to ask. It asks the text colour now, which always contrasts with whatever it sits on. That brings the shift to 29 points, inside the 19 to 33 the other five sit in. The heads-up badge had the same shape of bug from the other direction: it painted its label in the tone colour on a translucent control surface, over a ground already tinted with that same tone. Amber on amber, illegible, and only visible now that launch mode can be opened at all. A badge is a surface rather than media, so it takes an opaque tone fill with ink chosen from that tone's own luminance. Swept all six themes on the rebuilt rows, since the earlier sweep predates them. --- .../java/com/papi/nova/ui/NovaGameDetailContent.kt | 11 ++++++++--- .../com/papi/nova/ui/NovaGameDetailDestinations.kt | 12 +++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 6ac733bf..1f21168d 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.colorResource import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties @@ -1189,9 +1191,12 @@ internal fun LaunchProfilePrimaryNotice( ) { NovaBadge( text = badgeLabel, - color = toneColor, - backgroundColor = surfaces.control.copy(alpha = 0.72f * LocalNovaMenuOpacityScale.current), - borderColor = toneColor.copy(alpha = 0.35f), + // A badge is a surface, not media: translucent control over a ground + // already tinted with this tone put amber on amber. Opaque tone, with + // ink picked from the tone itself, reads in every theme. + color = if (toneColor.luminance() > 0.5f) Color.Black else Color.White, + backgroundColor = toneColor, + borderColor = Color.Transparent, fontWeight = FontWeight.SemiBold ) Text( diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index 962e92b7..56f387d9 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -454,7 +455,16 @@ internal fun NovaSteamChoiceRow( val accentBar = colors.accent val hairline = colors.divider.copy(alpha = 0.45f) val barWidth = NOVA_DETAIL_ROW_FOCUS_BAR - val tint = colors.accent.copy(alpha = 0.16f) + // The accent is light on a dark surface and dark on a light one, so the same alpha + // is a whisper in one theme and an inverted block in the other. Scale it by the + // polarity; the bar, not the fill, is what says this row has focus. + // + // Polarity comes from the text rather than colors.window, because under Portable + // Chrome the panel takes its lightness from surfaces.panel layered over the window, + // so the window is the wrong ground to ask and the tint stayed at full strength. + val tint = colors.accent.copy( + alpha = if (colors.textPrimary.luminance() < 0.5f) 0.07f else 0.16f, + ) Row( verticalAlignment = Alignment.CenterVertically, From 01a54f03639c07245e1a033fc34d2d1974affef7 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:49:04 -0400 Subject: [PATCH 14/24] feat(nova): mark the cut where a drawer body scrolls under its hint bar The concept pins the header and the hint bar and dissolves the last 52dp of the body, so what is below the fold reads as continuing rather than as clipped. Until now the insight simply collided with the hint bar mid-word. The fade erases content alpha rather than painting a ground over it. The panel is translucent, so a solid band would have striped window colour across the artwork showing through it. --- .../nova/ui/NovaGameDetailDestinations.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index 56f387d9..505edac2 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -39,16 +39,21 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -129,6 +134,7 @@ internal fun NovaGameDetailPanel( modifier = Modifier .weight(1f) .fillMaxWidth() + .novaFadeAtCut() .novaHoldsFirstFocus() .verticalScroll(scrollState), content = { content() }, @@ -153,6 +159,29 @@ private fun Modifier.novaHoldsFirstFocus(): Modifier { return focusRequester(requester).focusGroup() } +/** + * Dissolves the last band of a scrolling body, so what passes under the hint bar reads + * as continuing rather than as clipped. It erases content alpha instead of painting a + * ground: the panel is translucent, and a solid band would stripe window colour across + * the artwork showing through it. + */ +private fun Modifier.novaFadeAtCut(): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val fade = NOVA_DETAIL_BOTTOM_FADE.toPx().coerceAtMost(size.height) + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Black, Color.Transparent), + startY = size.height - fade, + endY = size.height, + ), + topLeft = Offset(0f, size.height - fade), + size = Size(size.width, fade), + blendMode = BlendMode.DstIn, + ) + } + /** A tap target that swallows the gesture, with no ripple to imply a button. */ private fun Modifier.novaDismissOnTap(onDismiss: () -> Unit): Modifier = composed { clickable( @@ -203,6 +232,7 @@ internal fun NovaGameDetailFullScreen( modifier = Modifier .weight(1f) .fillMaxWidth() + .novaFadeAtCut() .novaHoldsFirstFocus() .verticalScroll(scrollState), content = { content() }, @@ -549,6 +579,9 @@ private val NOVA_DETAIL_ROW_MIN_HEIGHT = 48.dp /** The focused row grows a bar at its edge instead of a border that moves it. */ private val NOVA_DETAIL_ROW_FOCUS_BAR = 3.dp +/** The body dissolves over this much before the hint bar, marking the cut. */ +private val NOVA_DETAIL_BOTTOM_FADE = 52.dp + /** * A scrim is a shadow, not a surface, so it does not follow the theme. Painting it in * the window colour turned into a white veil under Portable Chrome. From 8387d373810ab6270b64ff7a7289542c20affff5 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:54:15 -0400 Subject: [PATCH 15/24] feat(nova): give the launch mode you pick somewhere to live Choosing in the Launch Mode destination did nothing. It wrote into launchMode.preferredMode, and that field means the app's own default, which the resolver deliberately places below the host's configured streamDisplayMode. So the row went on reading Selected against the mode you had just moved away from, and the choice only appeared to stick in the window before client settings had loaded, which is why it looked like staleness and flip-flopped between visits. The hierarchy itself is right and two tests pin it, so this does not reorder anything. The contract keeps being written exactly as before, so Polaris still learns the preference. What is new is a per-game override kept on this client and consulted above the host default, written only by someone actually choosing in that destination. A game nobody has chosen for resolves exactly as it did. Verified on the Retroid: choosing writes launch_mode_override_, the row updates without leaving the destination, and it still reads the same after a cold start, which is the case that used to disagree with itself. --- .../papi/nova/ui/NovaGameDetailActivity.kt | 4 ++- .../com/papi/nova/ui/NovaGameDetailUiState.kt | 14 +++++++- .../papi/nova/ui/NovaLaunchModeOverrides.kt | 34 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt index 7b631a6e..420914d7 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailActivity.kt @@ -415,6 +415,7 @@ class NovaGameDetailActivity : NovaActivity() { allowedModes = allowedModes ) currentGame = currentGame.copy(launchMode = updatedLaunchMode) + NovaLaunchModeOverrides.save(this@NovaGameDetailActivity, currentGame, mode) refreshUiState() optimizationState = NovaGameDetailOptimizationState() loadOptimization(profilePreference, usesVirtualDisplay = mode == "virtual_display") @@ -817,7 +818,8 @@ class NovaGameDetailActivity : NovaActivity() { game = game, defaultToVirtualDisplay = defaultToVirtualDisplay, clientSettings = clientSettings, - profilePreference = profilePreference + profilePreference = profilePreference, + launchModeOverride = NovaLaunchModeOverrides.load(this@NovaGameDetailActivity, game), ) } diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt index a86ef644..0286dc0f 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt @@ -43,10 +43,22 @@ data class NovaGameDetailUiState( game: PolarisGame, defaultToVirtualDisplay: Boolean, clientSettings: PolarisClientSettings?, - profilePreference: String + profilePreference: String, + /** A mode chosen for this game on this client, which outranks the host default. */ + launchModeOverride: String? = null, ): NovaGameDetailUiState { val choice = game.resolveLaunchModeChoice(defaultToVirtualDisplay, clientSettings) + // Only a deliberate choice reaches here, so it answers before the host does. + // The contract's preferredMode stays below the host, as it means the app's own + // default rather than anyone's decision. + val chosen = launchModeOverride + ?.takeIf { it.isNotBlank() } + ?.let { PolarisGame.resolveLaunchMode(it, choice.headlessAllowed, choice.virtualDisplayAllowed) } val playMode = when { + chosen == "virtual_display" && choice.virtualDisplayAllowed && + !choice.virtualDisplayUnavailable -> "virtual_display" + chosen == "headless" && choice.headlessAllowed -> "headless" + choice.recommendedMode == "virtual_display" && choice.virtualDisplayAllowed -> "virtual_display" choice.recommendedMode == "headless" && choice.headlessAllowed -> "headless" choice.headlessAllowed -> "headless" diff --git a/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt new file mode 100644 index 00000000..3db15b84 --- /dev/null +++ b/app/src/main/java/com/papi/nova/ui/NovaLaunchModeOverrides.kt @@ -0,0 +1,34 @@ +package com.papi.nova.ui + +import android.content.Context +import com.papi.nova.shared.polaris.model.PolarisGame + +/** + * Where the launch mode you choose for a game is remembered. + * + * It cannot live in the game's launch contract: preferredMode there means the app's own + * default and is deliberately outranked by the host's configured display mode, so a + * choice written into it is resolved away. This is a separate, higher answer to the same + * question — what this client should do for this game — and it is only ever written by + * choosing in the Launch Mode destination. + */ +object NovaLaunchModeOverrides { + + private const val PREFS_NAME = "nova_prefs" + private const val KEY_PREFIX = "launch_mode_override_" + + private fun key(game: PolarisGame): String = + KEY_PREFIX + game.id.ifBlank { game.appId.toString() } + + fun load(context: Context, game: PolarisGame): String? = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(key(game), null) + ?.takeIf { it.isNotBlank() } + + fun save(context: Context, game: PolarisGame, mode: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(key(game), mode) + .apply() + } +} From d52a2770d12dc22e6b95a26717c189ce9958b72b Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:02:25 -0400 Subject: [PATCH 16/24] chore(nova): drop imports the sheet left behind Splitting the bottom sheet into an activity and its content moved the fragment, dialog and coroutine machinery out but left their imports where they were, and Android lint does not fail on those. Forty-four of them across the two drawer files, each confirmed unused by the compiler rather than by eye. The two delegation imports that carry no textual reference, getValue and setValue, are deliberately kept: by mutableStateOf needs them and nothing names them. --- .../com/papi/nova/ui/NovaGameDetailContent.kt | 39 ------------------- .../nova/ui/NovaGameDetailDestinations.kt | 5 --- 2 files changed, 44 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 1f21168d..1f12812f 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -1,21 +1,9 @@ package com.papi.nova.ui -import android.app.Dialog -import android.content.Context -import android.content.res.Configuration -import android.os.Bundle -import android.text.format.DateUtils -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.ImageView -import android.widget.ScrollView -import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.focusGroup @@ -26,11 +14,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -38,7 +23,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding @@ -46,7 +30,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -66,8 +49,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -76,41 +57,21 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.core.widget.NestedScrollView import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.papi.nova.LimeLog import com.papi.nova.R import com.papi.nova.api.PolarisApiClient import com.papi.nova.api.PolarisArtworkChoice import com.papi.nova.api.PolarisArtworkMatchCandidate -import com.papi.nova.api.PolarisClientSettings -import com.papi.nova.api.PolarisStreamDisplayMode import com.papi.nova.shared.polaris.model.PolarisGame -import com.papi.nova.manager.StreamSyncManager -import com.papi.nova.preferences.PreferenceConfiguration import com.papi.nova.ui.compose.LocalNovaComposeColors import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces import com.papi.nova.ui.compose.LocalNovaMenuOpacityScale import com.papi.nova.ui.compose.NovaActionButton import com.papi.nova.ui.compose.NovaBadge -import com.papi.nova.ui.compose.NovaComposeTheme import com.papi.nova.ui.compose.NovaControllerHint -import com.papi.nova.ui.compose.NovaControllerHintBar import com.papi.nova.ui.compose.NovaFocusableCard -import com.papi.nova.utils.DeviceUtils -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.json.JSONObject -import java.util.Locale -import kotlin.math.abs -import kotlin.math.round internal fun canPublishArtworkMutationUiForState(state: Lifecycle.State?): Boolean = state?.isAtLeast(Lifecycle.State.CREATED) == true diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index 505edac2..3de94ccb 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight @@ -23,19 +22,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -69,7 +65,6 @@ import com.papi.nova.ui.compose.LocalNovaComposeColors import com.papi.nova.ui.compose.LocalNovaLibrarySurfaces import com.papi.nova.ui.compose.NovaControllerHint import com.papi.nova.ui.compose.NovaControllerHintBar -import com.papi.nova.ui.compose.NovaFocusableCard /** The three ways a launch can go when Polaris reports desktop Steam active. */ internal enum class NovaSteamLaunchChoice { From 04904812dcddd03665b4b311ff74be830519802c Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:03:51 -0400 Subject: [PATCH 17/24] test(nova): make the drawer order guards assert existence, not just order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two assertions I re-pointed tonight compared indexes with a bare greater-than. That passes vacuously when the left-hand string disappears, since a missing needle returns -1 and any real position beats it — which is exactly how the MangoHUD guard came to be asserting nothing once the composable it referenced was deleted. Both move to the in-0-until form already used elsewhere in this file, which says both things exist and one precedes the other. --- .../java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt index 3124a629..469cebc2 100644 --- a/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt +++ b/app/src/test/java/com/papi/nova/ui/NovaComposeSourceGuardTest.kt @@ -1127,7 +1127,8 @@ class NovaComposeSourceGuardTest { detail.contains("private fun showLaunchOptions(") && detail.contains("onLaunchOptions = {") && launchControls.contains("label = launchOptionsLabel") && - launchControls.indexOf("label = launchOptionsLabel") > launchControls.indexOf("label = headlessModeLabel") + launchControls.indexOf("label = headlessModeLabel") in + 0 until launchControls.indexOf("label = launchOptionsLabel") ) assertTrue( "launch mode should answer where it runs and leave the profile alone: preference, summary and reset are Tune's", @@ -1164,7 +1165,8 @@ class NovaComposeSourceGuardTest { "when MangoHUD is already enabled, the drawer should show only a passive status after launch controls", sheetContent.contains("if (mangoHudEnabled) {") && sheetContent.contains("MangoHudPassiveStatus(") && - sheetContent.indexOf("MangoHudPassiveStatus(") > sheetContent.indexOf("LaunchControls(") + sheetContent.indexOf("LaunchControls(") in + 0 until sheetContent.indexOf("MangoHudPassiveStatus(") ) } From 8f17f7f28031825d421be630520a0ac75391cfa0 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:11:30 -0400 Subject: [PATCH 18/24] feat(nova): let the drawer rows reach the panel's edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drawer was full width but its rows were not. The panel padded its whole column by the 28dp inset and then again by the safe area, so every divider, focus tint and accent bar stopped about 57dp short of each edge. The list read as a narrow column floating on a wide empty surface, which is the gap on either side. The inset moves off the body and onto the things that want it — header, hint bar, group labels, prose, the notice — so a row spans the panel and carries its own inset for its text. The safe area splits by axis with it: a cutout must not eat text, but it need not stop a row background from reaching the edge it is drawn against, so the panel keeps the vertical inset and everything holding text takes the horizontal one. The focus bar now starts at the edge it is meant to mark, and the hairline separates the whole width rather than a column of it. --- .../com/papi/nova/ui/NovaGameDetailContent.kt | 3 ++ .../nova/ui/NovaGameDetailDestinations.kt | 29 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 1f12812f..830ce222 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -1032,6 +1032,7 @@ private fun LaunchControls( Column { Text( text = launchIntro, + modifier = Modifier.padding(horizontal = NovaGameDetailInset), color = if (uiState.virtualDisplayUnavailable) colors.warning else colors.textSecondary, fontSize = 11.sp, lineHeight = 14.sp, @@ -1140,6 +1141,7 @@ internal fun LaunchProfilePrimaryNotice( Column( modifier = Modifier .fillMaxWidth() + .padding(horizontal = NovaGameDetailInset) .padding(top = 8.dp) .clip(RoundedCornerShape(12.dp)) .background(toneColor.copy(alpha = 0.14f)) @@ -1482,6 +1484,7 @@ private fun InsightCard(card: NovaGameDetailInsightCard) { Box( modifier = Modifier .fillMaxWidth() + .padding(horizontal = NovaGameDetailInset) .padding(top = 12.dp, bottom = 2.dp), ) { Column { diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt index 3de94ccb..5a84c9fd 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailDestinations.kt @@ -13,11 +13,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.width @@ -114,8 +116,10 @@ internal fun NovaGameDetailPanel( .background(surfaces.panel) // Taps inside the panel are not taps outside it. .novaDismissOnTap {} - .windowInsetsPadding(WindowInsets.safeContent) - .padding(horizontal = NovaGameDetailInset, vertical = if (shortViewport) 10.dp else 20.dp) + // Vertical only. A cutout must not eat text, but it need not stop a row + // background from reaching the edge it is drawn against. + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Vertical)) + .padding(vertical = if (shortViewport) 10.dp else 20.dp) .testTag("nova-game-detail-panel"), ) { NovaGameDetailDestinationHeader( @@ -252,7 +256,11 @@ private fun NovaGameDetailDestinationHints() { ), ), compact = true, - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = NovaGameDetailInset) + .padding(top = 10.dp), ) } @@ -267,7 +275,11 @@ private fun NovaGameDetailDestinationHeader( val colors = LocalNovaComposeColors.current Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(bottom = if (compact) 6.dp else 14.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = NovaGameDetailInset) + .padding(bottom = if (compact) 6.dp else 14.dp), ) { Column(modifier = Modifier.weight(1f)) { if (!compact) { @@ -341,7 +353,11 @@ internal fun NovaGameDetailGroupLabel(text: String) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 6.dp), + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = NovaGameDetailInset) + .padding(top = 16.dp, bottom = 6.dp), ) { Text( text = text.uppercase(), @@ -518,7 +534,8 @@ internal fun NovaSteamChoiceRow( size = Size(size.width, 1f), ) } - .padding(start = 12.dp, end = 2.dp, top = 9.dp, bottom = 9.dp) + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Horizontal)) + .padding(start = NovaGameDetailInset, end = NovaGameDetailInset, top = 9.dp, bottom = 9.dp) .semantics { contentDescription = if (value.isBlank()) label else "$label. $value" }, ) { Column(modifier = Modifier.weight(1f)) { From ad1e89c88ca35ae8b00f06e9ad38c8b9078e19b2 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:58:36 -0400 Subject: [PATCH 19/24] feat(nova): carry the launcher playtime the host now reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polaris reads minutes from Steam localconfig.vdf and serves them as playtime_minutes. Nova carries it through the model and the adapter, clamped at zero so a host that says something impossible reads the same as one that says nothing: no duration to show. The field sits last in the constructor rather than beside lastLaunched where it reads best. Sixteen places build PolarisGame positionally, and a parameter inserted into the middle shifts every one of them silently — the nvstream contract test caught it by passing a Boolean where a Long had just appeared. Serialisation is by name, so the position costs nothing. No UI yet: the gauge waits until a host is actually serving the field, so it can be built against a real number rather than a zero. --- .../papi/nova/api/PolarisGameJsonAdapter.kt | 1 + .../nova/api/PolarisApiClientParsingTest.kt | 19 +++++++++++++++++++ .../nova/shared/polaris/model/PolarisGame.kt | 10 +++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt index 214bf197..006e10f9 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt @@ -43,6 +43,7 @@ object PolarisGameJsonAdapter { coverUrl = json.optString("cover_url", ""), genres = fetchStringArray(json.optJSONArray("genres")), lastLaunched = json.optLong("last_launched", 0), + playtimeMinutes = json.optLong("playtime_minutes", 0).coerceAtLeast(0), mangohud = json.optBoolean("mangohud", false), hdrSupported = json.optBoolean("hdr_supported", false), launchMode = launchMode, diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index 7b10f013..44404265 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -991,4 +991,23 @@ class PolarisApiClientParsingTest { assertEquals("big-picture", body.getString("mode")) } + @Test + fun playtimeMinutesAreCarriedFromTheHostAndNeverNegative() { + val game = PolarisGameJsonAdapter.fromJson( + JSONObject( + """{id:abc,app_id:1,name:Control,playtime_minutes:1684}""" + ) + ) + assertEquals(1684L, game.playtimeMinutes) + + // A host that says nothing, and one that says something impossible, both mean + // there is no duration to show rather than a negative one. + val silent = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}""")) + assertEquals(0L, silent.playtimeMinutes) + + val nonsense = PolarisGameJsonAdapter.fromJson( + JSONObject("""{id:abc,name:Control,playtime_minutes:-5}""") + ) + assertEquals(0L, nonsense.playtimeMinutes) + } } diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt index c6e6eb6c..7605ae39 100644 --- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt +++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt @@ -26,7 +26,15 @@ data class PolarisGame( @SerialName("launch_mode") val launchMode: LaunchModeContract? = null, @SerialName("steam_launch") val steamLaunch: SteamLaunchContract? = null, @SerialName("display_planner") val displayPlanner: DisplayPlannerContract? = null, - @SerialName("artwork") val artwork: ArtworkManifest? = null + @SerialName("artwork") val artwork: ArtworkManifest? = null, + /** + * Minutes the owning launcher says this has been played; 0 when nothing local knows. + * + * Last in the list on purpose. Sixteen places build this positionally, so a field + * inserted beside lastLaunched where it reads best would silently shift every one of + * them. Serialisation is by name, so position costs nothing here. + */ + @SerialName("playtime_minutes") val playtimeMinutes: Long = 0 ) { @Serializable data class ArtworkManifest( From c8ac25a14463f56d9c3dd7512adf4f9dc0d04b1d Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:28:26 -0400 Subject: [PATCH 20/24] feat(nova): show how long a game has been played MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polaris serves play_time now, read from the launcher that owns the game rather than accumulated from streamed sessions — a game with two hundred hours at the desk would otherwise read as however long it had been streamed to a handheld. Carried as an object rather than a number, and nullable rather than zeroed, because the concept is explicit that absent is not zero: a game nobody has played and a game no launcher can speak for are different answers, and only one of them should ever read "Not started". The concept draws a gauge whose notches are the three How Long To Beat estimates. None exist yet, and it says what to draw in that case — the played figure alone, no bar, since there is nothing to measure against. So that is what this is, and the bar arrives with the data rather than ahead of it. It sits under the hairline that divides names from numbers, because a duration is a number. The field is last in the constructor rather than beside lastLaunched where it reads best. Sixteen places build PolarisGame positionally, and a parameter inserted into the middle shifts every one of them silently; the nvstream contract test caught it by passing a Boolean where a Long had just appeared. Verified end to end on the Retroid against a live host: Phasmophobia reads 199 h, matching the 11983 minutes in this machine's localconfig.vdf. --- .../papi/nova/api/PolarisGameJsonAdapter.kt | 8 +++++- .../papi/nova/ui/NovaGameDetailOverview.kt | 25 +++++++++++++++++++ app/src/main/res/values/strings.xml | 2 ++ .../nova/api/PolarisApiClientParsingTest.kt | 25 +++++++++++-------- .../nova/shared/polaris/model/PolarisGame.kt | 15 +++++++++-- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt index 006e10f9..9fa7fc7e 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt @@ -43,7 +43,13 @@ object PolarisGameJsonAdapter { coverUrl = json.optString("cover_url", ""), genres = fetchStringArray(json.optJSONArray("genres")), lastLaunched = json.optLong("last_launched", 0), - playtimeMinutes = json.optLong("playtime_minutes", 0).coerceAtLeast(0), + playTime = json.optJSONObject("play_time")?.let { played -> + PolarisGame.PlayTime( + seconds = played.optLong("seconds", 0).coerceAtLeast(0), + source = played.optString("source", ""), + readAt = played.optLong("read_at", 0).coerceAtLeast(0) + ) + }, mangohud = json.optBoolean("mangohud", false), hdrSupported = json.optBoolean("hdr_supported", false), launchMode = launchMode, diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index df184599..3de521f2 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -178,6 +178,31 @@ internal fun NovaGameDetailOverview( ), ) + // A duration is a number, so it sits below the hairline with the rest of them. + // The concept's gauge needs How Long To Beat to have something to measure + // against; until that exists it draws the played figure alone, and a game no + // launcher owns draws nothing rather than claiming nobody has played it. + uiState.game.playTime?.takeIf { it.seconds > 0 }?.let { played -> + val minutes = played.seconds / 60 + Text( + text = if (minutes >= 60) { + stringResource(R.string.nova_game_detail_played_hours, minutes / 60) + } else { + stringResource(R.string.nova_game_detail_played_minutes, minutes) + }, + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.17.em, + maxLines = 1, + // these are measurements, so the digits line up rather than dance + style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"), + modifier = Modifier + .padding(top = 9.dp) + .testTag("nova-game-detail-played"), + ) + } + NovaGameDetailStatusLine( uiState = uiState, optimizationState = optimizationState, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77be5501..a7e27e47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,8 @@ Actions Insight Close + %1$d h played + %1$d min played Streams without touching the physical desktop A dedicated display on the host, at your resolution Resolution, bitrate and codec for this launch diff --git a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt index 44404265..a37dbd38 100644 --- a/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt +++ b/app/src/test/java/com/papi/nova/api/PolarisApiClientParsingTest.kt @@ -992,22 +992,25 @@ class PolarisApiClientParsingTest { } @Test - fun playtimeMinutesAreCarriedFromTheHostAndNeverNegative() { - val game = PolarisGameJsonAdapter.fromJson( + fun playTimeIsCarriedFromTheHostAndAbsentIsNotZero() { + val played = PolarisGameJsonAdapter.fromJson( JSONObject( - """{id:abc,app_id:1,name:Control,playtime_minutes:1684}""" + """{id:abc,name:Control,play_time:{seconds:143520,source:steam,read_at:1754470000}}""" ) ) - assertEquals(1684L, game.playtimeMinutes) + assertEquals(143520L, played.playTime?.seconds) + assertEquals("steam", played.playTime?.source) - // A host that says nothing, and one that says something impossible, both mean - // there is no duration to show rather than a negative one. - val silent = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}""")) - assertEquals(0L, silent.playtimeMinutes) + // No launcher owns the answer: null, so the gauge can be omitted rather than + // drawn claiming nobody has played it. + val unowned = PolarisGameJsonAdapter.fromJson(JSONObject("""{id:abc,name:Control}""")) + assertNull(unowned.playTime) - val nonsense = PolarisGameJsonAdapter.fromJson( - JSONObject("""{id:abc,name:Control,playtime_minutes:-5}""") + // Owned, but never played, is a different answer and keeps its object. + val untouched = PolarisGameJsonAdapter.fromJson( + JSONObject("""{id:abc,name:Control,play_time:{seconds:0,source:steam}}""") ) - assertEquals(0L, nonsense.playtimeMinutes) + assertNotNull(untouched.playTime) + assertEquals(0L, untouched.playTime?.seconds) } } diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt index 7605ae39..690d8722 100644 --- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt +++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt @@ -28,13 +28,16 @@ data class PolarisGame( @SerialName("display_planner") val displayPlanner: DisplayPlannerContract? = null, @SerialName("artwork") val artwork: ArtworkManifest? = null, /** - * Minutes the owning launcher says this has been played; 0 when nothing local knows. + * How long the owning launcher says this has been played, or null when none can say. + * + * Null rather than zero: a game nobody has played and a game no launcher owns are + * different answers, and only one of them should read "Not started". * * Last in the list on purpose. Sixteen places build this positionally, so a field * inserted beside lastLaunched where it reads best would silently shift every one of * them. Serialisation is by name, so position costs nothing here. */ - @SerialName("playtime_minutes") val playtimeMinutes: Long = 0 + @SerialName("play_time") val playTime: PlayTime? = null ) { @Serializable data class ArtworkManifest( @@ -49,6 +52,14 @@ data class PolarisGame( fun asset(kind: String): ArtworkAsset? = assets.asset(kind) } + /** What a launcher says about time spent, normalised to seconds before it travels. */ + @Serializable + data class PlayTime( + @SerialName("seconds") val seconds: Long = 0, + @SerialName("source") val source: String = "", + @SerialName("read_at") val readAt: Long = 0 + ) + @Serializable data class ArtworkMatch( @SerialName("source") val source: String = "", From 821795d944a4222a4233bb9cd559c811b3d467f8 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:44:01 -0400 Subject: [PATCH 21/24] feat(nova): draw the play time gauge against the completion estimates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Polaris serves beat_time now, matched from a dataset on the host rather than fetched from a third party at runtime. With both halves present the Overview draws what the concept drew: played hours at the left, the three estimates at the right, and a bar whose full width is the completionist figure with notches where main and extras fall. The partial cases are the point, and each is the concept's own answer. Played with no estimates keeps the figure alone, because there is nothing to measure it against. Estimates with nothing played read "Not started" over an empty track. Neither draws nothing at all, and the hairline above still separates identity from machine state. Played past the end caps the bar and lets the figure keep counting, since the hours you actually spent are the honest number even when they run off the end of someone else's estimate. Every figure is optional on its own: a catalogue that knows the main story but not the completionist run says so rather than padding the gap with a zero, and the bar falls back to the longest figure it actually has. Verified on the Retroid against a live host: Phasmophobia reads 199 h played against 6 · 20 · 50 h to beat, with the bar capped and both notches at the twelve and forty percent marks the estimates put them at. --- .../papi/nova/api/PolarisGameJsonAdapter.kt | 10 ++ .../papi/nova/ui/NovaGameDetailOverview.kt | 138 +++++++++++++++--- app/src/main/res/values/strings.xml | 2 + .../nova/shared/polaris/model/PolarisGame.kt | 24 ++- 4 files changed, 153 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt index 9fa7fc7e..e51652c8 100644 --- a/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt +++ b/app/src/main/java/com/papi/nova/api/PolarisGameJsonAdapter.kt @@ -43,6 +43,16 @@ object PolarisGameJsonAdapter { coverUrl = json.optString("cover_url", ""), genres = fetchStringArray(json.optJSONArray("genres")), lastLaunched = json.optLong("last_launched", 0), + beatTime = json.optJSONObject("beat_time")?.let { beat -> + PolarisGame.BeatTime( + mainSeconds = beat.optLong("main_seconds", 0).coerceAtLeast(0), + extrasSeconds = beat.optLong("extras_seconds", 0).coerceAtLeast(0), + completionistSeconds = beat.optLong("completionist_seconds", 0).coerceAtLeast(0), + matchedName = beat.optString("matched_name", ""), + url = beat.optString("url", ""), + cachedAt = beat.optLong("cached_at", 0).coerceAtLeast(0) + ) + }, playTime = json.optJSONObject("play_time")?.let { played -> PolarisGame.PlayTime( seconds = played.optLong("seconds", 0).coerceAtLeast(0), diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index 3de521f2..aaf77a2f 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -1,6 +1,7 @@ package com.papi.nova.ui import android.widget.ImageView +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -43,6 +44,8 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -182,26 +185,10 @@ internal fun NovaGameDetailOverview( // The concept's gauge needs How Long To Beat to have something to measure // against; until that exists it draws the played figure alone, and a game no // launcher owns draws nothing rather than claiming nobody has played it. - uiState.game.playTime?.takeIf { it.seconds > 0 }?.let { played -> - val minutes = played.seconds / 60 - Text( - text = if (minutes >= 60) { - stringResource(R.string.nova_game_detail_played_hours, minutes / 60) - } else { - stringResource(R.string.nova_game_detail_played_minutes, minutes) - }, - color = colors.textSecondary, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.17.em, - maxLines = 1, - // these are measurements, so the digits line up rather than dance - style = LocalTextStyle.current.copy(fontFeatureSettings = "tnum"), - modifier = Modifier - .padding(top = 9.dp) - .testTag("nova-game-detail-played"), - ) - } + NovaGameDetailBeatGauge( + playTime = uiState.game.playTime, + beatTime = uiState.game.beatTime, + ) NovaGameDetailStatusLine( uiState = uiState, @@ -489,6 +476,117 @@ private fun NovaGameDetailActions( } } +/** + * How long this has been played, against how long it takes. + * + * The concept is explicit about what each partial case draws, because a gauge that + * guesses is worse than one that says less: + * + * - both a bar whose full width is the completionist figure, notched where main + * and extras fall, played hours at the left and the estimates at the right + * - played only the played figure alone; there is nothing to measure it against + * - estimate only "Not started", and the notches on an empty track + * - neither nothing at all, and the hairline above still separates identity from state + * - past the end the bar caps and the figure keeps counting, since that is the true number + */ +@Composable +private fun NovaGameDetailBeatGauge( + playTime: PolarisGame.PlayTime?, + beatTime: PolarisGame.BeatTime?, +) { + val colors = LocalNovaComposeColors.current + val surfaces = LocalNovaLibrarySurfaces.current + + val playedSeconds = playTime?.seconds?.takeIf { it > 0 } ?: 0L + val fullWidthSeconds = beatTime?.longestSeconds?.takeIf { it > 0 } ?: 0L + if (playedSeconds <= 0L && fullWidthSeconds <= 0L) { + return + } + + val figures = LocalTextStyle.current.copy(fontFeatureSettings = "tnum") + + Column(modifier = Modifier.padding(top = 9.dp).testTag("nova-game-detail-played")) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.width(NOVA_GAUGE_WIDTH), + ) { + Text( + text = if (playedSeconds > 0L) { + val minutes = playedSeconds / 60 + if (minutes >= 60) { + stringResource(R.string.nova_game_detail_played_hours, minutes / 60) + } else { + stringResource(R.string.nova_game_detail_played_minutes, minutes) + } + } else { + stringResource(R.string.nova_game_detail_not_started) + }, + color = colors.textSecondary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.17.em, + maxLines = 1, + style = figures, + modifier = Modifier.weight(1f), + ) + + if (beatTime != null) { + val estimates = listOf( + beatTime.mainSeconds, + beatTime.extrasSeconds, + beatTime.completionistSeconds, + ).filter { it > 0 }.joinToString(" · ") { "${it / 3600}" } + + if (estimates.isNotEmpty()) { + Text( + text = stringResource(R.string.nova_game_detail_to_beat, estimates), + color = colors.textMuted, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.17.em, + maxLines = 1, + style = figures, + ) + } + } + } + + if (fullWidthSeconds > 0L) { + val track = surfaces.tileBorder + val fill = colors.accent + val notchInk = colors.textMuted + val played = (playedSeconds.toFloat() / fullWidthSeconds.toFloat()).coerceIn(0f, 1f) + val notches = listOfNotNull( + beatTime?.mainSeconds?.takeIf { it > 0 && it < fullWidthSeconds }, + beatTime?.extrasSeconds?.takeIf { it > 0 && it < fullWidthSeconds }, + ).map { it.toFloat() / fullWidthSeconds.toFloat() } + + Canvas( + modifier = Modifier + .padding(top = 6.dp) + .width(NOVA_GAUGE_WIDTH) + .height(4.dp), + ) { + drawRect(color = track, size = size) + if (played > 0f) { + drawRect(color = fill, size = Size(size.width * played, size.height)) + } + notches.forEach { fraction -> + drawRect( + color = notchInk, + topLeft = Offset(size.width * fraction, 0f), + size = Size(NOVA_GAUGE_NOTCH.toPx(), size.height), + ) + } + } + } + } +} + +/** Matches the hairline above it, so the two read as one column. */ +private val NOVA_GAUGE_WIDTH = 330.dp +private val NOVA_GAUGE_NOTCH = 2.dp + /** Dissolves the hero band into the window colour instead of cutting against it. */ private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent { drawContent() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7e27e47..77bef67c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -271,6 +271,8 @@ Actions Insight Close + Not started + %1$s h to beat %1$d h played %1$d min played Streams without touching the physical desktop diff --git a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt index 690d8722..9fdc2a0f 100644 --- a/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt +++ b/shared/polaris/model/src/commonMain/kotlin/com/papi/nova/shared/polaris/model/PolarisGame.kt @@ -37,7 +37,9 @@ data class PolarisGame( * inserted beside lastLaunched where it reads best would silently shift every one of * them. Serialisation is by name, so position costs nothing here. */ - @SerialName("play_time") val playTime: PlayTime? = null + @SerialName("play_time") val playTime: PlayTime? = null, + /** Completion estimates, or null when the host's dataset has nothing for this game. */ + @SerialName("beat_time") val beatTime: BeatTime? = null ) { @Serializable data class ArtworkManifest( @@ -52,6 +54,26 @@ data class PolarisGame( fun asset(kind: String): ArtworkAsset? = assets.asset(kind) } + /** + * What the host's dataset says about finishing this game. + * + * Every figure is optional on its own: a catalogue that knows the main story but not + * the completionist run should say so rather than pad the gap with a zero. + */ + @Serializable + data class BeatTime( + @SerialName("main_seconds") val mainSeconds: Long = 0, + @SerialName("extras_seconds") val extrasSeconds: Long = 0, + @SerialName("completionist_seconds") val completionistSeconds: Long = 0, + @SerialName("matched_name") val matchedName: String = "", + @SerialName("url") val url: String = "", + @SerialName("cached_at") val cachedAt: Long = 0 + ) { + /** The bar's full width, falling back through what is actually known. */ + val longestSeconds: Long + get() = maxOf(completionistSeconds, extrasSeconds, mainSeconds) + } + /** What a launcher says about time spent, normalised to seconds before it travels. */ @Serializable data class PlayTime( From 1da4555a3a2eb3b6b589521dda598a8465b44200 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:06:53 -0400 Subject: [PATCH 22/24] fix(nova): draw the gauge the way the concept draws it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass was written from the concept's prose — played hours left, estimates right — which got the arrangement right and everything else wrong. The drawing says more than the sentence did. Each estimate is labelled, because three bare numbers do not say which is which. The played figure is the bright one and the estimates are dimmer, so the row has an order to read it in. Both are uppercase at the tracking the rest of the identity block uses. The bar is rounded and filled with a gradient rather than flat, and the notches are cut through it in the ground colour with a light ring, overhanging top and bottom — which is the difference between a notch in the bar and a mark painted on it, and is visible only in the drawing. The estimate also becomes a control when the dataset carries a page for it: padded, rounded, and in the focus lane with the accent ring. Without a page it is only something to read, and stays out of the lane rather than offering a link to nowhere. Verified on the Retroid against live data: Phasmophobia reads 199 h played against main 20 h, extras 41 h and 100% 138 h, with the bar capped and both notches placed. --- .../papi/nova/ui/NovaGameDetailOverview.kt | 158 ++++++++++++++---- app/src/main/res/values/strings.xml | 4 +- 2 files changed, 124 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index aaf77a2f..8815fc55 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -2,6 +2,7 @@ package com.papi.nova.ui import android.widget.ImageView import androidx.compose.foundation.Canvas +import androidx.compose.foundation.focusable import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -36,6 +37,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.key +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role import androidx.compose.ui.res.stringResource import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +49,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -479,15 +483,20 @@ private fun NovaGameDetailActions( /** * How long this has been played, against how long it takes. * - * The concept is explicit about what each partial case draws, because a gauge that - * guesses is worse than one that says less: + * Drawn as the concept draws it: the played figure bright at the left, the three + * estimates dimmer at the right and labelled so a number means something, and a bar + * whose full width is the completionist figure with the other two cut through it as + * notches — ground-coloured with a light ring, overhanging top and bottom, so they read + * as gaps in the bar rather than marks on top of it. * - * - both a bar whose full width is the completionist figure, notched where main - * and extras fall, played hours at the left and the estimates at the right - * - played only the played figure alone; there is nothing to measure it against - * - estimate only "Not started", and the notches on an empty track - * - neither nothing at all, and the hairline above still separates identity from state - * - past the end the bar caps and the figure keeps counting, since that is the true number + * The partial cases are the concept's own answers, because a gauge that guesses is worse + * than one that says less: + * + * - both bar, notches, figure and estimates + * - played only the figure alone; there is nothing to measure it against + * - estimate only "Not started" over an empty track + * - neither nothing, and the hairline above still separates identity from state + * - past the end the bar caps and the figure keeps counting, which is the true number */ @Composable private fun NovaGameDetailBeatGauge( @@ -496,6 +505,7 @@ private fun NovaGameDetailBeatGauge( ) { val colors = LocalNovaComposeColors.current val surfaces = LocalNovaLibrarySurfaces.current + val uriHandler = LocalUriHandler.current val playedSeconds = playTime?.seconds?.takeIf { it > 0 } ?: 0L val fullWidthSeconds = beatTime?.longestSeconds?.takeIf { it > 0 } ?: 0L @@ -504,8 +514,9 @@ private fun NovaGameDetailBeatGauge( } val figures = LocalTextStyle.current.copy(fontFeatureSettings = "tnum") + var estimateFocused by remember { mutableStateOf(false) } - Column(modifier = Modifier.padding(top = 9.dp).testTag("nova-game-detail-played")) { + Column(modifier = Modifier.padding(top = 10.dp).testTag("nova-game-detail-played")) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.width(NOVA_GAUGE_WIDTH), @@ -520,41 +531,84 @@ private fun NovaGameDetailBeatGauge( } } else { stringResource(R.string.nova_game_detail_not_started) - }, - color = colors.textSecondary, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.17.em, + }.uppercase(), + color = colors.textPrimary, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.12.em, maxLines = 1, style = figures, - modifier = Modifier.weight(1f), ) - if (beatTime != null) { - val estimates = listOf( - beatTime.mainSeconds, - beatTime.extrasSeconds, - beatTime.completionistSeconds, - ).filter { it > 0 }.joinToString(" · ") { "${it / 3600}" } + Spacer(modifier = Modifier.weight(1f)) + + val parts = listOfNotNull( + beatTime?.mainSeconds?.takeIf { it > 0 } + ?.let { stringResource(R.string.nova_game_detail_beat_main, it / 3600) }, + beatTime?.extrasSeconds?.takeIf { it > 0 } + ?.let { stringResource(R.string.nova_game_detail_beat_extras, it / 3600) }, + beatTime?.completionistSeconds?.takeIf { it > 0 } + ?.let { stringResource(R.string.nova_game_detail_beat_complete, it / 3600) }, + ) + + if (parts.isNotEmpty()) { + val page = beatTime?.url.orEmpty() + val linked = page.isNotBlank() + val ring = if (estimateFocused && linked) colors.accent else Color.Transparent - if (estimates.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS)) + .background( + if (estimateFocused && linked) { + colors.accent.copy(alpha = 0.14f) + } else { + Color.Transparent + } + ) + .border(1.dp, ring, RoundedCornerShape(NOVA_GAUGE_CHIP_RADIUS)) + .then( + // A page makes this a control; without one it is a readout and + // has no business in the focus lane. + if (linked) { + Modifier + .onFocusChanged { estimateFocused = it.isFocused || it.hasFocus } + .clickable(role = Role.Button) { uriHandler.openUri(page) } + .focusable() + } else { + Modifier + } + ) + .padding(horizontal = 7.dp, vertical = 3.dp), + ) { Text( - text = stringResource(R.string.nova_game_detail_to_beat, estimates), - color = colors.textMuted, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.17.em, + text = parts.joinToString(" · ").uppercase(), + color = colors.textSecondary, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.10.em, maxLines = 1, style = figures, ) + if (linked) { + Text( + text = "\u2197", + color = if (estimateFocused) colors.accent else colors.textMuted, + fontSize = 10.sp, + ) + } } } } if (fullWidthSeconds > 0L) { - val track = surfaces.tileBorder - val fill = colors.accent - val notchInk = colors.textMuted + val track = colors.textPrimary.copy(alpha = 0.10f) + val fillEnd = colors.accent + val fillStart = lerp(colors.accent, Color.White, 0.28f) + val notchInk = colors.window + val notchRing = colors.textPrimary.copy(alpha = 0.34f) val played = (playedSeconds.toFloat() / fullWidthSeconds.toFloat()).coerceIn(0f, 1f) val notches = listOfNotNull( beatTime?.mainSeconds?.takeIf { it > 0 && it < fullWidthSeconds }, @@ -565,17 +619,43 @@ private fun NovaGameDetailBeatGauge( modifier = Modifier .padding(top = 6.dp) .width(NOVA_GAUGE_WIDTH) - .height(4.dp), + .height(NOVA_GAUGE_BAR + NOVA_GAUGE_NOTCH_OVERHANG * 2), ) { - drawRect(color = track, size = size) + val barTop = NOVA_GAUGE_NOTCH_OVERHANG.toPx() + val barHeight = NOVA_GAUGE_BAR.toPx() + val radius = CornerRadius(barHeight / 2f, barHeight / 2f) + + drawRoundRect( + color = track, + topLeft = Offset(0f, barTop), + size = Size(size.width, barHeight), + cornerRadius = radius, + ) if (played > 0f) { - drawRect(color = fill, size = Size(size.width * played, size.height)) + drawRoundRect( + brush = Brush.horizontalGradient(listOf(fillStart, fillEnd)), + topLeft = Offset(0f, barTop), + size = Size(size.width * played, barHeight), + cornerRadius = radius, + ) } + + val notchWidth = NOVA_GAUGE_NOTCH.toPx() + val ringWidth = notchWidth + 2f notches.forEach { fraction -> - drawRect( + val centre = size.width * fraction + // The ring first, so the notch reads as a gap cut through the bar. + drawRoundRect( + color = notchRing, + topLeft = Offset(centre - ringWidth / 2f, 0f), + size = Size(ringWidth, size.height), + cornerRadius = CornerRadius(ringWidth / 2f, ringWidth / 2f), + ) + drawRoundRect( color = notchInk, - topLeft = Offset(size.width * fraction, 0f), - size = Size(NOVA_GAUGE_NOTCH.toPx(), size.height), + topLeft = Offset(centre - notchWidth / 2f, 1f), + size = Size(notchWidth, size.height - 2f), + cornerRadius = CornerRadius(notchWidth / 2f, notchWidth / 2f), ) } } @@ -585,7 +665,11 @@ private fun NovaGameDetailBeatGauge( /** Matches the hairline above it, so the two read as one column. */ private val NOVA_GAUGE_WIDTH = 330.dp -private val NOVA_GAUGE_NOTCH = 2.dp +private val NOVA_GAUGE_BAR = 4.dp +private val NOVA_GAUGE_NOTCH = 1.5.dp +/** The notches overhang the bar, which is what makes them read as cuts through it. */ +private val NOVA_GAUGE_NOTCH_OVERHANG = 2.dp +private val NOVA_GAUGE_CHIP_RADIUS = 5.dp /** Dissolves the hero band into the window colour instead of cutting against it. */ private fun Modifier.novaFadeToGround(ground: Color): Modifier = drawWithContent { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77bef67c..3a2d4419 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,7 +272,9 @@ Insight Close Not started - %1$s h to beat + Main %1$d h + Extras %1$d h + 100%% %1$d h %1$d h played %1$d min played Streams without touching the physical desktop From 0ac6c6fcd18588148fbea0db8f4f0430504495bf Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:22:13 -0400 Subject: [PATCH 23/24] feat(nova): say which game an estimate is actually for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estimate comes from a fuzzy match against a catalogue, and a wrong match looks exactly like a right one: a plausible number, presented as fact. The concept carries matched_name for this reason — so a bad match is visible and correctable, the way an artwork match already is. Shown only when the names genuinely differ. Punctuation and case disagree constantly between a launcher and a catalogue, and "Control Ultimate Edition" against "Control: Ultimate Edition" is the same answer; saying so would bury the cases that matter. A different word or numeral is not the same answer, so "Slay the Spire 2" matched as "Slay the Spire II" says so, and so would the day something matches the wrong game. Verified on the Retroid: Slay the Spire 2 reads its estimate with the line, Control carries the same difference in the dataset and correctly stays quiet. --- .../papi/nova/ui/NovaGameDetailOverview.kt | 32 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 2 files changed, 33 insertions(+) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt index 8815fc55..e67b641d 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailOverview.kt @@ -190,6 +190,7 @@ internal fun NovaGameDetailOverview( // against; until that exists it draws the played figure alone, and a game no // launcher owns draws nothing rather than claiming nobody has played it. NovaGameDetailBeatGauge( + gameName = uiState.game.name, playTime = uiState.game.playTime, beatTime = uiState.game.beatTime, ) @@ -500,6 +501,7 @@ private fun NovaGameDetailActions( */ @Composable private fun NovaGameDetailBeatGauge( + gameName: String, playTime: PolarisGame.PlayTime?, beatTime: PolarisGame.BeatTime?, ) { @@ -660,7 +662,37 @@ private fun NovaGameDetailBeatGauge( } } } + + // A fuzzy match that went wrong looks exactly like one that went right, so the + // name it actually found is shown whenever it is not plainly the same game. + val matched = beatTime?.matchedName.orEmpty() + if (matched.isNotBlank() && novaSameTitle(matched, gameName).not()) { + Text( + text = stringResource(R.string.nova_game_detail_matched_as, matched), + color = colors.textMuted, + fontSize = 10.sp, + letterSpacing = 0.06.em, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 5.dp) + .width(NOVA_GAUGE_WIDTH), + ) + } + } +} + +/** + * Whether two titles are the same game as far as a reader cares. + * + * Punctuation and case disagree constantly between a launcher and a catalogue, and + * saying so every time would bury the cases that matter under noise. + */ +private fun novaSameTitle(left: String, right: String): Boolean { + fun fold(value: String) = buildString { + value.forEach { if (it.isLetterOrDigit()) append(it.lowercaseChar()) } } + return fold(left) == fold(right) } /** Matches the hairline above it, so the two read as one column. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a2d4419..9c400298 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,6 +272,7 @@ Insight Close Not started + Estimate for %1$s Main %1$d h Extras %1$d h 100%% %1$d h From a0358ec210c0872b213745db09b1a271c73bd545 Mon Sep 17 00:00:00 2001 From: papi <20916260+papi-ux@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:37:28 -0400 Subject: [PATCH 24/24] fix(nova): stop the launch mode badge claiming a recommendation nobody made recommendedMode falls back to preferredMode when neither the host nor the game contract says anything, so with nothing to go on it echoes the preference back. The badge then lands on whichever mode is not selected, and moves when the selection moves. What made it look random is that the host view arrives late. clientSettings starts null and is only filled during a launch preflight, so a freshly opened window resolves against the contract while a window that has already launched something resolves against the host. Same game, two answers, decided by history the reader cannot see. The badge now appears only when something actually recommended: the host said so, or the contract did. An echo of the preference is not a recommendation and no longer claims to be. The resolver is untouched, so the two tests pinning its hierarchy stay honest. Verified on the Retroid: a freshly opened Launch Mode reads Selected and Available, where it previously read Selected and Recommended for no reason it could point to. --- app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt | 6 ++++-- app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt index 830ce222..54e6560f 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailContent.kt @@ -1079,7 +1079,8 @@ private fun LaunchControls( onClick = { onLaunchModeSelected("headless") }, value = when { uiState.playMode == "headless" -> "Selected" - uiState.recommendedMode == "headless" && uiState.headlessAllowed -> "Recommended" + uiState.hasRecommendation && uiState.recommendedMode == "headless" && + uiState.headlessAllowed -> "Recommended" uiState.headlessAllowed -> "Available" else -> "Unavailable" }, @@ -1096,7 +1097,8 @@ private fun LaunchControls( value = when { uiState.virtualDisplayUnavailable -> "Unavailable" uiState.playMode == "virtual_display" -> "Selected" - uiState.recommendedMode == "virtual_display" && uiState.virtualDisplayAllowed -> "Recommended" + uiState.hasRecommendation && uiState.recommendedMode == "virtual_display" && + uiState.virtualDisplayAllowed -> "Recommended" uiState.virtualDisplayAllowed -> "Available" else -> "Unavailable" }, diff --git a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt index 0286dc0f..c5f8c1e8 100644 --- a/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt +++ b/app/src/main/java/com/papi/nova/ui/NovaGameDetailUiState.kt @@ -11,6 +11,8 @@ data class NovaGameDetailUiState( val launchChoice: PolarisGame.LaunchModeChoice, val preferredMode: String, val recommendedMode: String, + /** Whether anything actually recommended it, rather than it echoing the preference. */ + val hasRecommendation: Boolean, val headlessAllowed: Boolean, val virtualDisplayAllowed: Boolean, val virtualDisplayUnavailable: Boolean, @@ -96,6 +98,10 @@ data class NovaGameDetailUiState( launchChoice = choice, preferredMode = choice.preferredMode, recommendedMode = choice.recommendedMode, + // The resolver falls back to the preference when neither the host nor + // the contract recommends anything, so ask whether either did. + hasRecommendation = choice.hostDefaultMode.isNotBlank() || + !game.launchMode?.recommendedMode.isNullOrBlank(), headlessAllowed = choice.headlessAllowed, virtualDisplayAllowed = choice.virtualDisplayAllowed, virtualDisplayUnavailable = choice.virtualDisplayUnavailable,