diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 4909bb95..d14f4a95 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -84,5 +84,43 @@ class TitleAsyncFocusTest { assertFalse(shell.navFocused) } + @Test fun loadingPersonFocusesItsFirstCreditBeforeRealRemoteNavigation() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000013") + val reply = CompletableDeferred() + val facade = mockk() + coEvery { facade.readPersonSaved(id) } returns null + coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true)) + val viewer = ShellPerson("justin", "Justin", "J", false) + val route = personRoute(id.toString()) + val shell = ShellState(listOf(viewer), viewer.key).apply { + pickerVisible = false + pickerLaunch = false + enterDestination(route) + } + rule.setContent { WolfskinTheme { + TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel) + } } + rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } + rule.waitForIdle() + assertTrue(shell.navFocused) + press(Key.DirectionDown) + assertTrue(shell.navFocused) + rule.runOnIdle { + reply.complete(org.justinvisser.wolfskin.tv.read.PersonRead( + org.justinvisser.wolfskin.design.TitlePerson(id.toString(), "Actor"), + listOf(TitleItem("first-credit", "First credit", "movie"), TitleItem("second-credit", "Second credit", "movie")), + )) + } + rule.waitUntil(timeoutMillis = 5000) { viewModel.person != null || viewModel.title?.data?.status == TitleReadStatus.Unavailable } + assertNotNull("A real person reply must populate the performer destination", viewModel.person) + rule.waitForIdle() + assertFalse("The loaded person must transfer nav ownership to its first credit", shell.navFocused) + assertEquals(0, viewModel.person!!.index) + press(Key.DirectionRight) + assertEquals(1, viewModel.person!!.index) + assertFalse(shell.navFocused) + } + private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 5db527f4..90d6fcc1 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -118,6 +118,7 @@ class TitleCaptureTest { File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2)) } recordTextMetrics(directory, number) + recordElements(directory, number) } private fun recordTextMetrics(directory: File, number: Int) { @@ -143,6 +144,20 @@ class TitleCaptureTest { File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2)) } + private fun recordElements(directory: File, number: Int) { + val tag = androidx.compose.ui.semantics.SemanticsProperties.TestTag + val description = androidx.compose.ui.semantics.SemanticsProperties.ContentDescription + val matcher = SemanticsMatcher.keyIsDefined(tag) or SemanticsMatcher.keyIsDefined(description) + val records = org.json.JSONArray() + rule.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().forEach { node -> + records.put(JSONObject() + .put("tag", if (node.config.contains(tag)) node.config[tag] else JSONObject.NULL) + .put("description", if (node.config.contains(description)) org.json.JSONArray(node.config[description]) else JSONObject.NULL) + .put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom)))) + } + File(directory, "st-${number.toString().padStart(3, '0')}-elements.json").writeText(records.toString(2)) + } + private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) } private fun open(state: TitleState, shell: ShellState, id: String) { option(state, id) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 178eeded..6df8426d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -300,5 +300,46 @@ class TitleKeysTest { val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) + val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f) + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun filmHeaderKeepsItsFixedCssStack() { + render() + assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f) + assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f) + assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() { + render("Andor") + assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) + } + + @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { + render(transform = { data -> + val source = data.versions.first().copy( + audio = List(3) { org.justinvisser.wolfskin.design.TitleTrack(it + 3, "Audio ${it + 1}") }, + subtitles = List(15) { org.justinvisser.wolfskin.design.TitleTrack(it + 20, "Subtitle ${it + 1}") }, + ) + data.copy(versions = listOf(source)) + }) + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "tracks" }) } + press(Key.DirectionCenter) + val bottom = rule.onRoot().fetchSemanticsNode().boundsInRoot.bottom + val labels = (1..3).map { "Audio $it" } + "Off" + (1..15).map { "Subtitle $it" } + "Done" + labels.forEachIndexed { index, label -> + if (index > 0) press(Key.DirectionDown) + rule.waitForIdle() + val bounds = rule.onNodeWithText(label, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + assertTrue("$label must have visible height: $bounds", bounds.height > 20) + assertTrue("$label stays above the viewport bottom: $bounds / $bottom", bounds.bottom <= bottom) + assertTrue("$label stays below the viewport top: $bounds", bounds.top >= 0) + } + press(Key.DirectionCenter) + assertNull(state.sheet) + assertFalse(shell.navFocused) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index f180eb38..296041b3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -166,4 +166,15 @@ class TitleReadFacadeTest { facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single())) coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) } } + @Test fun subtitleOffDisablesPlaybackRatherThanRestoringThePreferredLanguage() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { playback.saveTrackSelection(any(), any(), any(), MediaStreamType.SUBTITLE) } returns + com.github.damontecres.wholphin.data.model.ItemPlayback(userId = 1, itemId = firstId) + val data = facade.read(firstId, null, preferences) + facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = -1)) + coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.DISABLED, MediaStreamType.SUBTITLE) } + coVerify(exactly = 0) { playback.saveTrackSelection(any(), any(), com.github.damontecres.wholphin.data.model.TrackIndex.UNSPECIFIED, MediaStreamType.SUBTITLE) } + facade.apply(org.justinvisser.wolfskin.design.TitleAction.Track(data.watchTarget, data.versions.single(), audio = null, subtitle = 8)) + coVerify(exactly = 1) { playback.saveTrackSelection(any(), any(), 8, MediaStreamType.SUBTITLE) } + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index ee8fac28..bdad22a5 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -4,6 +4,7 @@ import android.content.Context import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService @@ -107,7 +108,7 @@ class TitleReadFacade @Inject constructor( val item = BaseItem(api.userLibraryApi.getItem(id).content) var choice = repository.currentUser?.let { playback.itemPlaybackDao.getItem(it, id) } action.audio?.let { choice = playback.saveTrackSelection(item, choice, it, MediaStreamType.AUDIO) } - playback.saveTrackSelection(item, choice, action.subtitle, MediaStreamType.SUBTITLE) + playback.saveTrackSelection(item, choice, if (action.subtitle == -1) TrackIndex.DISABLED else action.subtitle, MediaStreamType.SUBTITLE) } is TitleAction.Watched -> watched.setWatched(UUID.fromString(action.item.id), action.watched) else -> Unit diff --git a/docker/README.md b/docker/README.md index 1b3c6544..bd4f6c46 100644 --- a/docker/README.md +++ b/docker/README.md @@ -310,13 +310,15 @@ pretend those operations succeeded or fall back to Seerr. `TitleCaptureTest` writes the 34 approved title/person/options frames plus native text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title -compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to -title/person content and sheets; shared nav/library typography is unchanged. -`TitleKeysTest` replays real Android keys from title entry, including modal Back, -held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry -and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply -through the production destination/view-model, then exercises loaded options and -hardware sheet Back through real focus handling. +compensation and no wrapping-parity claim. Explicit CSS line boxes constrain title +headers, sheets, cast and poster captions without changing shared type tokens. +Fractional stack placement rounds only the accumulated position, preventing a +one-pixel-per-row drift. Focused controls share the canonical lift shadow. +`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row +entry, remembered columns, long-sheet visibility and full media-table geometry. +`TitleAsyncFocusTest` delays title and person replies through the real destination +and view-model, then exercises content focus and hardware Back. Subtitles Off +maps to the player's explicit disabled sentinel, not its unspecified default. The approved current gate uses exact title-entry suffixes, not padded launch paths. `title-press-paths.json` retains the full sequences and entry boundaries. diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 5ae0b699..e66ac6ec 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -374,6 +374,7 @@ "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, "plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"}, + "plateInlineDescent": {"$type":"dimension","px":6.6,"dp":3.3,"source":"index.html:156,162 inline-flex baseline strut; proto-104 rplate 100.2px minus 14+33.6+46"}, "plateFloor": {"$type":"typography","px":24,"sp":12,"weight":700,"$value":{"fontWeight":700},"source":"index.html:802"}, "plateStep": {"$type":"number","$value":0.25,"source":"spec 4.8"}, "plateGapEm": {"$type":"number","$value":0.64,"source":"index.html:164"}, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 5ab4bbb8..1f600caf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -24,7 +24,7 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) } Column(modifier.graphicsLayer { this.alpha = alpha.value }) { - Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, modifier = Modifier.cssLineBox(WolfskinTokens.Type.button), maxLines = 1, overflow = TextOverflow.Ellipsis) CaptionFacts(facts, accentFirstFact, accentLastFact) } } @@ -40,8 +40,8 @@ private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolea content = { parts.forEachIndexed { index, part -> val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) - Text(part, color = if (accented) colour else muted, style = style, maxLines = 1, overflow = TextOverflow.Clip) - if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style) + Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.cssLineBox(style), maxLines = 1, overflow = TextOverflow.Clip) + if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.cssLineBox(style)) } }, modifier = Modifier.fillMaxWidth().clipToBounds(), diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt new file mode 100644 index 00000000..b9da01ec --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt @@ -0,0 +1,74 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +/** CSS leading constrains the line box, even when the platform font's natural box is taller. */ +internal fun Modifier.cssLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier = + then(CssLineBox(style.lineHeight, top, bottom)) + +private data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssLineBox + fun Density.exactHeight(height: Int): Float { + val margins = top.toPx() + bottom.toPx() + return ((height - margins) / lineHeight.toPx()).roundToInt().coerceAtLeast(1) * lineHeight.toPx() + margins + } + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = with(measurable.measure(constraints.copy(minHeight = 0))) { + val line = lineHeight.toPx() + val lines = ((this[LastBaseline] - this[FirstBaseline]) / line).roundToInt().coerceAtLeast(0) + 1 + val textHeight = lines * line + val offset = top.toPx() + (textHeight - height) / 2f + layout(width, (textHeight + top.toPx() + bottom.toPx()).roundToInt()) { + placeRelative(0, offset.roundToInt()) + } + } +} + +private class CssColumnHeight : ParentDataModifier { + var pixels = 0f + override fun Density.modifyParentData(parentData: Any?): Any = parentData ?: this@CssColumnHeight +} + +/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ +@Composable +internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = 0.dp, content: @Composable () -> Unit) { + val exact = remember { CssColumnHeight() } + Layout(content, modifier.then(exact)) { children, constraints -> + val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } + fun height(index: Int): Float = when (val data = children[index].parentData) { + is CssLineBox -> with(data) { exactHeight(measured[index].height) } + is CssColumnHeight -> data.pixels + else -> measured[index].height.toFloat() + } + val space = gap.toPx() + var total = 0f + var width = constraints.minWidth + for (index in measured.indices) { + total += height(index) + if (index == measured.lastIndex) 0f else space + width = maxOf(width, measured[index].width) + } + exact.pixels = total + layout(width.coerceIn(constraints.minWidth, constraints.maxWidth), total.roundToInt().coerceIn(constraints.minHeight, constraints.maxHeight)) { + var y = 0f + for (index in measured.indices) { + measured[index].placeRelative(0, y.roundToInt()) + y += height(index) + space + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 37923f98..0ea4e763 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -87,6 +87,7 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List, p @Composable fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } + LaunchedEffect(state) { shell.navFocused = false } Box(modifier.fillMaxSize().background(C.bg)) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt index 380bb809..c7e015ab 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer @@ -46,7 +47,6 @@ fun TitleRatings(ratings: List, modifier: Modifier = Modifier, st Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { ratings.forEach { rating -> Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) { - if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) val resource = when (rating.source) { "rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh "rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright @@ -57,7 +57,8 @@ fun TitleRatings(ratings: List, modifier: Modifier = Modifier, st val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight), alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated) - Text(rating.formatted(), color = C.fg, style = numberStyle, maxLines = 1) + if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), modifier = Modifier.cssLineBox(textStyle)) + Text(rating.formatted(), color = C.fg, style = numberStyle, modifier = Modifier.cssLineBox(numberStyle), maxLines = 1) } } } @@ -72,7 +73,7 @@ fun LibraryRatingPlate(ratings: List, modifier: Modifier = Modifi val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate") BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha } .background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f))) - .padding(start = P.plateSide, end = P.plateSide, top = P.plateTop, bottom = P.plateBottom)) { + .testTag("rating-plate").padding(horizontal = P.plateSide)) { val width = with(density) { maxWidth.toPx() } val fit = remember(ratings, width, density, Locale.getDefault()) { var size = WolfskinTokens.Type.ratingPlate.fontSize.value @@ -99,7 +100,8 @@ fun LibraryRatingPlate(ratings: List, modifier: Modifier = Modifi count to size } val fontSize = TextUnit(fit.second, TextUnitType.Sp) - TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, - lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)), plate = true) + val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, + lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)) + TitleRatings(ratings.take(fit.first), modifier = Modifier.cssLineBox(style, top = P.plateTop, bottom = P.plateBottom + P.plateInlineDescent), style = style, plate = true) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index 9b3b6fdb..4346eae2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -37,9 +37,9 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) .background(background, shape) - .then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) + .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) @@ -53,7 +53,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod fun TitleRadio(selected: Boolean, focused: Boolean) { val color = if (focused) C.onFocus else C.fg Box(Modifier.size(D.radioSize).background(if (selected) C.accent else androidx.compose.ui.graphics.Color.Transparent, CircleShape) - .then(if (!selected) Modifier.border(D.radioBorder, color.copy(alpha = .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) { + .then(if (focused || !selected) Modifier.border(D.radioBorder, color.copy(alpha = if (focused) .5f else .35f), CircleShape) else Modifier), contentAlignment = Alignment.Center) { if (selected) Box(Modifier.size(D.radioDot).background(C.onFocus, CircleShape)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 366a0d0d..783bdacd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -37,50 +37,50 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } val item = state.data.item val type = if (item.kind == "movie") "Movie" else "Series" - Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { - Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { + CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { + Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon)) Text(buildString { append("$type · ${if (item.held) "In your library" else "Not in your library"}") if (item.held && state.watched) append(" · Watched") if (!item.held && item.otherOwners.isNotEmpty()) append(" · In ${item.otherOwners.joinToString(" and ")}’s library") if (state.data.hidden) append(" · Hidden from your picks") - }, color = C.accentText, style = D.dLine) + }, color = C.accentText, style = TitleTextStyles.dLine) } - Text(item.name, color = C.fg, style = WolfskinTokens.Type.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.titleBottom).testTag("title-heading")) - Row(Modifier.heightIn(min = L.iconCaptionLine + D.factsBottom).padding(bottom = D.factsBottom), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) { - item.year?.let { Text(it.toString(), color = C.muted, style = WolfskinTokens.Type.body) } - if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = WolfskinTokens.Type.body) - if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = WolfskinTokens.Type.body) + Text(item.name, color = C.fg, style = TitleTextStyles.displayDetail, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.displayDetail, bottom = D.titleBottom).testTag("title-heading")) + Row(Modifier.padding(bottom = D.factsBottom).height(D.factsHeight).testTag("title-facts"), horizontalArrangement = Arrangement.spacedBy(D.factsGap), verticalAlignment = Alignment.CenterVertically) { + item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) } + if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) + if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) - if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = WolfskinTokens.Type.body) + if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body)) } state.data.requestedStage?.let { stage -> val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } - Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.cssLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon)) - Text(stage, color = color, style = WolfskinTokens.Type.label) - Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine) + Text(stage, color = color, style = TitleTextStyles.label) + Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = TitleTextStyles.dLine) } } - Text(item.overview, color = C.muted, style = WolfskinTokens.Type.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = D.overviewGap)) + Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) if (!item.held && state.data.connection != TitleConnection.SignedIn) { Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", - color = C.warn, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.overviewGap)) + color = C.warn, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, bottom = D.overviewGap)) } state.data.playTarget?.let { target -> - Row(Modifier.offset(y = D.progressLift).padding(bottom = D.progressBottom), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.cssLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { if (target.resumeSeconds > 0) { Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) } - Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = WolfskinTokens.Type.label) - } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = WolfskinTokens.Type.label) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label) + Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) + } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress) } } - Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, onFocus = { state.focus("header", index) }) { state.click("header", index) } } @@ -100,11 +100,15 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie /** A no-copy load has no focusable content and never draws a spinner. */ @Composable fun TitleHeaderSkeleton(modifier: Modifier = Modifier) { - Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(D.factsBottom)) { + Column(modifier.offset(L.safeSide, L.detailCopyTop)) { Box(Modifier.size(D.skeletonKickerWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Spacer(Modifier.height(D.skeletonTitleGap)) Box(Modifier.size(D.skeletonTitleWidth, D.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + Spacer(Modifier.height(D.skeletonFactsGap)) Box(Modifier.size(D.skeletonFactsWidth, D.skeletonKickerHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.cardSheet))) + Spacer(Modifier.height(D.skeletonOverviewGap)) Box(Modifier.size(D.skeletonOverviewWidth, D.skeletonOverviewHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) + Spacer(Modifier.height(D.overviewGap)) Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Box(Modifier.size(D.skeletonPlayWidth, L.buttonHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) repeat(2) { Box(Modifier.size(L.iconButton).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.button))) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 711080da..8ece6e2f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -53,9 +53,9 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) { val key = readShellKey(event) key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) }.focusable()) { - Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) + CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), - verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) { + gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { when (state.sheet) { TitleSheet.Version -> VersionChoices(state) TitleSheet.Tracks -> TrackChoices(state) @@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) { @Composable private fun VersionChoices(state: TitleState) { - Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle) + Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle)) SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files") state.data.versions.forEachIndexed { index, version -> PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } } - Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption) + Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption)) } @Composable private fun TrackChoices(state: TitleState) { val version = state.version ?: return - Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle) + Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle)) SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}") - Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) + Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) version.audio.forEachIndexed { index, track -> PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } } - Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) + Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) val start = version.audio.size PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) } version.subtitles.forEachIndexed { index, track -> @@ -95,20 +95,20 @@ private fun TrackChoices(state: TitleState) { PlaybackChoice(track.label, null, track.index == state.subtitleIndex, state.sheetIndex == position, { state.sheetIndex = position }) { state.clickSheet(position) } } val done = start + version.subtitles.size + 1 - TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.trackGap), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) } + TitleControl(TitleOption("done", "Done", "", primary = true, icon = false), state.sheetIndex == done, Modifier.padding(top = D.sheetButtonTop).sheetFocused(state.sheetIndex == done), onFocus = { state.sheetIndex = done }) { state.clickSheet(done) } } @Composable private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { val foreground = if (focused) C.onFocus else C.fg val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) - Row(Modifier.fillMaxWidth().titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) + Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(label, color = foreground, style = TitleTextStyles.listTitle) - if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption) + CssColumn(Modifier.weight(1f)) { + Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.cssLineBox(TitleTextStyles.listTitle)) + if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption)) } TitleRadio(selected, focused) } @@ -117,20 +117,20 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus @Composable private fun MediaInformation(state: TitleState) { val version = state.version ?: return - Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle) + Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle)) SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name) MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known"))) MediaInfoSection("Video", listOf("Stream" to version.video)) MediaInfoSection("Audio", version.audio.mapIndexed { index, track -> "Track ${index + 1}" to (track.label + if (track.isDefault) " (default)" else "") }) MediaInfoSection("Subtitles", version.subtitles.mapIndexed { index, track -> "Track ${index + 1}" to track.label }) - TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } + TitleControl(TitleOption("close", "Close", "", primary = true, icon = false), true, Modifier.padding(top = D.sheetButtonTop).sheetFocused(true), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } } @Composable private fun MediaInfoSection(label: String, values: List>) { - Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) - Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { + Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.cssLineBox(TitleTextStyles.label, top = D.sheetLabelTop)) + CssColumn(gap = D.infoRowGap) { values.forEach { (name, value) -> + Row(Modifier.fillMaxWidth().cssLineBox(TitleTextStyles.caption), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth)) Text(value, color = C.fg, style = TitleTextStyles.caption) } @@ -140,21 +140,17 @@ private fun MediaInfoSection(label: String, values: List>) @Composable private fun WatchedConfirmation(state: TitleState) { val verb = if (state.watched) "unwatched" else "watched" - Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle) + Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle, modifier = Modifier.cssLineBox(TitleTextStyles.sheetTitle)) SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.") - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } - TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } + TitleControl(TitleOption("cancel", "Cancel", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } } } @Composable private fun SheetSubtitle(text: String) { - Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints -> - val child = measurable.measure(constraints) - val lift = D.sheetSubLift.roundToPx() - layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) } - }.padding(bottom = D.sheetSubBottom)) + Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.cssLineBox(TitleTextStyles.body, top = D.sheetSubLift, bottom = D.sheetSubBottom)) } @Composable @@ -167,8 +163,8 @@ private fun HiddenReceipt(state: TitleState) { } Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body) } - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } - TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, Modifier.sheetFocused(state.sheetIndex == 0), onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } + TitleControl(TitleOption("done", "Done", "", icon = false), state.sheetIndex == 1, Modifier.sheetFocused(state.sheetIndex == 1), onFocus = { state.sheetIndex = 1 }, overArtwork = false) { state.clickSheet(1) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt new file mode 100644 index 00000000..d6c46049 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleSheetFocus.kt @@ -0,0 +1,16 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier + +/** Logical remote focus must reveal its row even though Android focus stays on the modal owner. */ +@Composable +internal fun Modifier.sheetFocused(focused: Boolean): Modifier { + val request = remember { BringIntoViewRequester() } + LaunchedEffect(focused) { if (focused) request.bringIntoView() } + return bringIntoViewRequester(request) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 9d1a67cf..76bddd18 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -65,8 +65,8 @@ fun TitleCast(state: TitleState, active: Boolean) { .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape)) Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { - Text(person.name, color = C.fg, style = D.castName, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) - Text(person.character, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.cssLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) + Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.cssLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt index ffb534fe..69465238 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt @@ -8,6 +8,10 @@ internal object TitleTextStyles { private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes) + val displayDetail = WolfskinTokens.Type.displayDetail.withLineBoxes() + val dLine = WolfskinTokens.Title.dLine.withLineBoxes() + val progress = WolfskinTokens.Title.dLine.withLineBoxes() + val castName = WolfskinTokens.Title.castName.withLineBoxes() val body = WolfskinTokens.Type.body.withLineBoxes() val caption = WolfskinTokens.Type.caption.withLineBoxes() val label = WolfskinTokens.Type.label.withLineBoxes() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index d23a79bb..e208170f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -359,6 +359,7 @@ object WolfskinTokens { val plateTop = 7.0.dp val plateSide = 8.0.dp val plateBottom = 23.0.dp + val plateInlineDescent = 3.3.dp val plateFloor = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) const val plateStep = 0.25f const val plateGapEm = 0.64f @@ -419,6 +420,7 @@ object WolfskinTokens { val overviewGap = 17.0.dp val factsGap = 11.0.dp val factsBottom = 7.0.dp + val factsHeight = 19.0.dp val titleBottom = 8.0.dp val iconGap = 7.0.dp val iconSize = 16.0.dp @@ -441,6 +443,7 @@ object WolfskinTokens { val sheetSubLift = -4.0.dp val sheetSubBottom = 7.0.dp val sheetLabelTop = 4.0.dp + val sheetButtonTop = 6.0.dp val sheetRowSide = 13.0.dp val versionRowVertical = 10.0.dp val trackHeight = 42.0.dp @@ -462,6 +465,9 @@ object WolfskinTokens { const val fallbackHue = 260.0f val skeletonKickerWidth = 210.0.dp val skeletonKickerHeight = 17.0.dp + val skeletonTitleGap = 13.0.dp + val skeletonFactsGap = 12.0.dp + val skeletonOverviewGap = 15.0.dp val skeletonTitleWidth = 380.0.dp val skeletonTitleHeight = 46.0.dp val skeletonFactsWidth = 280.0.dp diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index eebba4c6..9f7ad2e6 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -5,6 +5,7 @@ "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, "factsGap": {"dp": 11, "px": 22, "source": "index.html:104"}, "factsBottom": {"dp": 7, "px": 14, "source": "index.html:104"}, + "factsHeight": {"dp": 19, "px": 38, "source": "index.html:104 .h-meta minimum height"}, "titleBottom": {"dp": 8, "px": 16, "source": "index.html:103"}, "iconGap": {"dp": 7, "px": 14, "source": "index.html:108"}, "iconSize": {"dp": 16, "px": 32, "source": "index.html:275"}, @@ -27,6 +28,7 @@ "sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"}, "sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"}, "sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"}, + "sheetButtonTop": {"dp": 6, "px": 12, "source": "index.html:241 .sheet .btn margin-top"}, "sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"}, "versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"}, "trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"}, @@ -48,6 +50,9 @@ "fallbackHue": {"$value": 260, "source": "index.html:130"}, "skeletonKickerWidth": {"dp": 210, "px": 420, "source": "index.html:1322"}, "skeletonKickerHeight": {"dp": 17, "px": 34, "source": "index.html:1322"}, + "skeletonTitleGap": {"dp": 13, "px": 26, "source": "index.html:1322"}, + "skeletonFactsGap": {"dp": 12, "px": 24, "source": "index.html:1322"}, + "skeletonOverviewGap": {"dp": 15, "px": 30, "source": "index.html:1322"}, "skeletonTitleWidth": {"dp": 380, "px": 760, "source": "index.html:1322"}, "skeletonTitleHeight": {"dp": 46, "px": 92, "source": "index.html:1322"}, "skeletonFactsWidth": {"dp": 280, "px": 560, "source": "index.html:1322"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index ecea63e1..16a9b6bd 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -227,6 +227,7 @@ --library-plateTop: 14.0px; --library-plateSide: 16.0px; --library-plateBottom: 46.0px; + --library-plateInlineDescent: 6.6px; --library-plateFloor: 24.0px; --library-railWidth: 52.0px; --library-railLetterHeight: 30.0px; @@ -263,6 +264,7 @@ --title-overviewGap: 34.0px; --title-factsGap: 22.0px; --title-factsBottom: 14.0px; + --title-factsHeight: 38.0px; --title-titleBottom: 16.0px; --title-iconGap: 14.0px; --title-iconSize: 32.0px; @@ -285,6 +287,7 @@ --title-sheetSubLift: -8.0px; --title-sheetSubBottom: 14.0px; --title-sheetLabelTop: 8.0px; + --title-sheetButtonTop: 12.0px; --title-sheetRowSide: 26.0px; --title-versionRowVertical: 20.0px; --title-trackHeight: 84.0px; @@ -305,6 +308,9 @@ --title-personInitialSize: 96.0px; --title-skeletonKickerWidth: 420.0px; --title-skeletonKickerHeight: 34.0px; + --title-skeletonTitleGap: 26.0px; + --title-skeletonFactsGap: 24.0px; + --title-skeletonOverviewGap: 30.0px; --title-skeletonTitleWidth: 760.0px; --title-skeletonTitleHeight: 92.0px; --title-skeletonFactsWidth: 560.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index c3b6aade..c9645c4a 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,7 +1,7 @@ 1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json 407ec3f8cd78f4a59d037879ef066921fa0cf1c4ee81c14c8c2913d815c02305 generate_tokens.py -276e8972052059732d19f2f19af6f10b4220b2f87237890a8f306f93f674a7a7 shell-tokens.json -4c429a7d5aac5c62f558d7091776b2fe94e3ad9ddc40df74798962543728574e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -bc82f477611669fbc6dd0d3e6559b1e05b0ad280531a883102803ffb0ebcdecb title-tokens.json -a13b3dc369e8d5b1ca1d6de4f49daa8eafbeb822bf4984290f66e7c0e692d911 tokens.css +be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json +9b2b4971e655c7acf3d6ce514696eb02415f55c933b5f6a5d62b96c3803ed4ef src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json +336cf853d492fedf7aa85e1d5aa6d01ecb7a92c2efca483bd490649910c145c5 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json