Reserve Home hero primary action space
All checks were successful
review/code-claude Code APPROVE: delta f375d880 fine
review/design-claude Design PASS: gear restored, hero buttons stationary

This commit is contained in:
justinzeus 2026-11-07 19:46:17 +01:00
parent 7c67596cb6
commit f375d88030
3 changed files with 73 additions and 4 deletions

View file

@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
@ -46,10 +47,21 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val detailsOnly = row.heroKind == "pick" || !row.watchReady
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
val detailsOnly = row.heroKind == "pick"
if (!detailsOnly) Layout(
modifier = if (row.watchReady) Modifier.lateLineFade(title.id) else Modifier,
content = {
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button) { if (row.watchReady) onButton(0) }
},
) { measurables, constraints ->
val button = measurables.single().measure(constraints)
// Reserve the real primary width; an unplaced button is neither drawn nor interactive.
layout(button.width, button.height) { if (row.watchReady) button.placeRelative(0, 0) }
}
val detailsColumn = if (detailsOnly || !row.watchReady) 0 else 1
PickerButton("Details", focused && column == detailsColumn, detailsOnly, "about", onFocus = { onHover(detailsColumn) }, textStyle = T.button) { onButton(detailsColumn) }
}
}
}

View file

@ -0,0 +1,53 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Fresh watch data may enable playback, but cannot move an already-visible Details action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeHeroActionsTest {
@get:Rule val rule = createComposeRule()
@Test fun latePlaybackReadinessKeepsDetailsBoundsForResumePlayAndEpisodeLabels() {
val title = HomeTitle("arrival", "Arrival", "movie", overview = "A linguist investigates a mysterious arrival.")
val episode = title.copy(id = "episode", type = "episode", season = 3, episode = 7)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "hero-actions").apply { mkdirs() }
val current = mutableStateOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(title), heroKind = "resume", watchReady = false))
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
HomeHero(current.value, false, 0, emptyList(), {}, {})
}
} }
for ((kind, item) in listOf("resume" to episode, "new" to title, "next" to episode)) {
rule.runOnIdle { current.value = current.value.copy(cards = listOf(item), heroKind = kind, watchReady = false) }
rule.mainClock.advanceTimeBy(1000)
val before = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot
val primary = (if (kind == "resume") "Resume" else "Play") + if (item.isEpisode) " ${item.episodeLabel}" else ""
rule.onNodeWithText(primary).assertIsNotDisplayed()
rule.onRoot().captureRoboImage(File(directory, "$kind-pending.png").absolutePath)
rule.runOnIdle { current.value = current.value.copy(watchReady = true) }
rule.mainClock.advanceTimeBy(400)
val after = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot
rule.onRoot().captureRoboImage(File(directory, "$kind-ready.png").absolutePath)
assertEquals("Late $primary must not move Details", before, after)
}
}
}