Use BringIntoViewSpec for TV home focus alignment

This commit is contained in:
2026-04-06 18:38:20 +02:00
parent 2937ee836b
commit 32d50f750f
4 changed files with 448 additions and 99 deletions

View File

@@ -9,20 +9,24 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.LibraryItem
import hu.bbara.purefin.feature.shared.home.NextUpItem
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.tv.home.TvHomeScreen
import hu.bbara.purefin.ui.theme.AppTheme
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.util.UUID
@@ -140,6 +144,157 @@ class TvHomeContentTest {
.assertIsFocused()
}
@Test
fun tvHomeScreen_movesFocusedSectionToTopOfContent() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_skipsMissingSectionAndAlignsLibraryToTop() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = emptyList(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Arrival")
assertSectionRowAlignedToViewportTop(tvHomeLibraryRowTag(sampleLibraryId()))
}
@Test
fun tvHomeScreen_movesPreviousSectionToTopWhenNavigatingUp() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUp(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionUp)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("The Train Job")
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
}
@Test
fun tvHomeScreen_horizontalMoveDoesNotChangeAlignedSectionPosition() {
composeRule.setContent {
AppTheme {
Box(
modifier = Modifier.size(width = 960.dp, height = 540.dp)
) {
TvHomeScreen(
libraries = sampleLibraries(),
libraryContent = sampleLibraryContent(),
continueWatching = sampleContinueWatching(),
nextUp = sampleNextUpRow(),
serverUrl = "",
onMovieSelected = {},
onSeriesSelected = {},
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
composeRule.waitForIdle()
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionDown)
}
composeRule.waitForIdle()
assertSectionRowAlignedToViewportTop(TvHomeNextUpRowTag)
val alignedTopBefore = rowTop(TvHomeNextUpRowTag)
composeRule.onRoot().performKeyInput {
pressKey(Key.DirectionRight)
}
composeRule.waitForIdle()
composeRule.onNodeWithTag(TvHomeHeroTitleTag).assertTextEquals("Safe")
val alignedTopAfter = rowTop(TvHomeNextUpRowTag)
assertEquals(alignedTopBefore, alignedTopAfter, 2f)
}
private fun sampleContinueWatching(): List<ContinueWatchingItem> {
return listOf(
ContinueWatchingItem(
@@ -174,10 +329,39 @@ class TvHomeContentTest {
)
}
private fun sampleNextUp(): List<NextUpItem> {
return listOf(
NextUpItem(
episode = sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
)
)
}
private fun sampleNextUpRow(): List<NextUpItem> {
return listOf(
NextUpItem(
episode = sampleEpisode(
id = "66666666-6666-6666-6666-666666666666",
title = "The Train Job"
)
),
NextUpItem(
episode = sampleEpisode(
id = "77777777-7777-7777-7777-777777777777",
title = "Safe",
releaseDate = "2024-02-09"
)
)
)
}
private fun sampleLibraries(): List<LibraryItem> {
return listOf(
LibraryItem(
id = UUID.fromString("33333333-3333-3333-3333-333333333333"),
id = sampleLibraryId(),
name = "Movies",
type = CollectionType.MOVIES,
posterUrl = "",
@@ -187,7 +371,7 @@ class TvHomeContentTest {
}
private fun sampleLibraryContent(): Map<UUID, List<PosterItem>> {
val libraryId = UUID.fromString("33333333-3333-3333-3333-333333333333")
val libraryId = sampleLibraryId()
return mapOf(
libraryId to listOf(
@@ -204,6 +388,32 @@ class TvHomeContentTest {
)
}
private fun sampleEpisode(
id: String,
title: String,
releaseDate: String = "2002-09-20",
progress: Double? = 18.0,
seriesId: UUID = UUID.fromString("88888888-8888-8888-8888-888888888888"),
seasonId: UUID = UUID.fromString("99999999-9999-9999-9999-999999999999")
): Episode {
return Episode(
id = UUID.fromString(id),
seriesId = seriesId,
seasonId = seasonId,
index = 1,
title = title,
synopsis = "A crew member takes the shuttle for a spin and makes a mess.",
releaseDate = releaseDate,
rating = "16+",
runtime = "44m",
progress = progress,
watched = false,
format = "HD",
imageUrlPrefix = "https://images.unsplash.com/photo-1511497584788-876760111969",
cast = emptyList()
)
}
private fun sampleMovie(
id: String,
title: String,
@@ -227,4 +437,29 @@ class TvHomeContentTest {
cast = emptyList()
)
}
private fun sampleLibraryId(): UUID {
return UUID.fromString("33333333-3333-3333-3333-333333333333")
}
private fun assertSectionRowAlignedToViewportTop(sectionRowTag: String) {
val expectedRowTop = viewportTop() + with(composeRule.density) {
TvHomeFocusedItemTopOffset.toPx()
}
assertEquals(expectedRowTop, rowTop(sectionRowTag), 2f)
}
private fun viewportTop(): Float {
return composeRule.onNodeWithTag(TvHomeContentViewportTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
private fun rowTop(sectionRowTag: String): Float {
return composeRule.onNodeWithTag(sectionRowTag, useUnmergedTree = true)
.fetchSemanticsNode()
.boundsInRoot
.top
}
}

View File

@@ -0,0 +1,63 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.ui.unit.dp
import kotlin.math.abs
internal val TvHomeFocusedItemTopOffset = 56.dp
internal val TvHomeBringIntoViewTrailingSpace = 120.dp
private const val TvHomeRowPivotParentFraction = 0.3f
private const val TvHomeRowPivotChildFraction = 0f
internal fun tvHomeColumnBringIntoViewSpec(topOffsetPx: Float): BringIntoViewSpec {
return object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val leadingEdge = offset
val trailingEdge = offset + size
val childSize = abs(trailingEdge - leadingEdge)
val childSmallerThanParent = childSize <= containerSize
val spaceAvailableToShowItem = containerSize - topOffsetPx
val targetForLeadingEdge =
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
containerSize - childSize
} else {
topOffsetPx
}
return leadingEdge - targetForLeadingEdge
}
}
}
internal val TvHomeRowBringIntoViewSpec: BringIntoViewSpec =
object : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
val leadingEdge = offset
val trailingEdge = offset + size
val childSize = abs(trailingEdge - leadingEdge)
val childSmallerThanParent = childSize <= containerSize
val initialTargetForLeadingEdge =
TvHomeRowPivotParentFraction * containerSize -
(TvHomeRowPivotChildFraction * childSize)
val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
val targetForLeadingEdge =
if (childSmallerThanParent && spaceAvailableToShowItem < childSize) {
containerSize - childSize
} else {
initialTargetForLeadingEdge
}
return leadingEdge - targetForLeadingEdge
}
}

View File

@@ -1,14 +1,18 @@
@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
package hu.bbara.purefin.tv.home.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
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
@@ -17,10 +21,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.feature.shared.home.ContinueWatchingItem
import hu.bbara.purefin.feature.shared.home.FocusableItem
@@ -30,6 +32,7 @@ import hu.bbara.purefin.feature.shared.home.PosterItem
import org.jellyfin.sdk.model.UUID
internal const val TvHomeInitialFocusTag = "tv-home-initial-focus-item"
internal const val TvHomeContentViewportTag = "tv-home-content-viewport"
@Composable
fun TvHomeContent(
@@ -52,14 +55,18 @@ fun TvHomeContent(
nextUp = nextUp
)
val visibleLibraries = itemRegistry.visibleLibraries
val (nextUpRef, continueWatchingRef) = remember { FocusRequester.createRefs() }
val libraryRefs = remember(visibleLibraries) {
visibleLibraries.associate { it.id to FocusRequester() }
}
val initialFocusRequester = remember { FocusRequester() }
val hasContinueWatching = continueWatching.isNotEmpty()
val hasNextUp = nextUp.isNotEmpty()
val hasLibraries = visibleLibraries.isNotEmpty()
val hasVisibleContent = hasContinueWatching || hasNextUp || hasLibraries
val firstVisibleLibraryId = visibleLibraries.firstOrNull()?.id
val initialFocusRequester = remember { FocusRequester() }
val firstAvailableItemKey = itemRegistry.firstAvailableItemId
var initialFocusApplied by remember { mutableStateOf(false) }
val topOffsetPx = with(LocalDensity.current) { TvHomeFocusedItemTopOffset.toPx() }
val columnBringIntoViewSpec = remember(topOffsetPx) {
tvHomeColumnBringIntoViewSpec(topOffsetPx = topOffsetPx)
}
LaunchedEffect(firstAvailableItemKey, initialFocusApplied) {
if (!initialFocusApplied && firstAvailableItemKey != null) {
@@ -69,81 +76,90 @@ fun TvHomeContent(
}
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(scheme.background),
contentPadding = contentPadding
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
item {
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { continueWatching.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { continueWatching.isNotEmpty() },
modifier = Modifier.focusRequester(continueWatchingRef)
.focusProperties {
down = nextUpRef
}
)
}
item {
Spacer(modifier = Modifier.height(20.dp))
}
item {
TvNextUpSection(
items = nextUp,
onFocusedItem = onMediaFocused,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
firstItemTestTag = TvHomeInitialFocusTag
.takeIf { continueWatching.isEmpty() && nextUp.isNotEmpty() },
modifier = Modifier.focusRequester(nextUpRef)
.focusProperties {
up = continueWatchingRef
libraryRefs.values.firstOrNull()?.let { down = it }
}
)
}
item {
Spacer(modifier = Modifier.height(20.dp))
}
items(
items = visibleLibraries,
key = { it.id }
) { item ->
val ref = libraryRefs[item.id]
TvLibraryPosterSection(
title = item.name,
items = libraryContent[item.id] ?: emptyList(),
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf {
continueWatching.isEmpty() &&
nextUp.isEmpty() &&
item.id == firstVisibleLibraryId
},
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
continueWatching.isEmpty() &&
nextUp.isEmpty() &&
item.id == firstVisibleLibraryId
},
modifier = (if (ref != null) Modifier.focusRequester(ref) else Modifier)
.focusProperties {
up = nextUpRef
libraryRefs.values.dropWhile { it != ref }.drop(1).firstOrNull()
?.let { down = it }
}
)
Spacer(modifier = Modifier.height(20.dp))
CompositionLocalProvider(LocalBringIntoViewSpec provides columnBringIntoViewSpec) {
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(scheme.background)
.testTag(TvHomeContentViewportTag),
contentPadding = contentPadding
) {
item {
Spacer(modifier = Modifier.height(8.dp))
}
if (hasContinueWatching) {
item {
TvContinueWatchingSection(
items = continueWatching,
onFocusedItem = onMediaFocused,
onMovieSelected = onMovieSelected,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester,
firstItemTestTag = TvHomeInitialFocusTag,
rowTestTag = TvHomeContinueWatchingRowTag
)
}
}
if (hasContinueWatching && (hasNextUp || hasLibraries)) {
item {
Spacer(modifier = Modifier.height(20.dp))
}
}
if (hasNextUp) {
item {
TvNextUpSection(
items = nextUp,
onFocusedItem = onMediaFocused,
onEpisodeSelected = onEpisodeSelected,
firstItemFocusRequester = initialFocusRequester.takeIf { !hasContinueWatching },
firstItemTestTag = TvHomeInitialFocusTag.takeIf { !hasContinueWatching },
rowTestTag = TvHomeNextUpRowTag
)
}
}
if (hasLibraries && (hasContinueWatching || hasNextUp)) {
item {
Spacer(modifier = Modifier.height(20.dp))
}
}
itemsIndexed(
items = visibleLibraries,
key = { _, library -> library.id }
) { index, library ->
TvLibraryPosterSection(
title = library.name,
items = libraryContent[library.id].orEmpty(),
onFocusedItem = onMediaFocused,
firstItemFocusRequester = initialFocusRequester.takeIf {
!hasContinueWatching &&
!hasNextUp &&
library.id == firstVisibleLibraryId
},
firstItemTestTag = TvHomeInitialFocusTag.takeIf {
!hasContinueWatching &&
!hasNextUp &&
library.id == firstVisibleLibraryId
},
rowTestTag = tvHomeLibraryRowTag(library.id),
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = onEpisodeSelected
)
if (index < visibleLibraries.lastIndex) {
Spacer(modifier = Modifier.height(20.dp))
}
}
if (hasVisibleContent) {
item {
Spacer(modifier = Modifier.height(TvHomeBringIntoViewTrailingSpace))
}
}
}
}
}

View File

@@ -1,9 +1,11 @@
package hu.bbara.purefin.tv.home.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -15,12 +17,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -57,6 +61,12 @@ private val TvHomeSectionsHorizontalPadding = 32.dp
private val TvHomeSectionsRowSpacing = 18.dp
private val TvHomeLandscapeCardWidth = 248.dp
private val TvHomePosterCardWidth = 136.dp
internal const val TvHomeSectionRowTagPrefix = "tv-home-section-row-"
internal const val TvHomeContinueWatchingRowTag = "${TvHomeSectionRowTagPrefix}continue-watching"
internal const val TvHomeNextUpRowTag = "${TvHomeSectionRowTagPrefix}next-up"
internal fun tvHomeLibraryRowTag(libraryId: UUID): String =
"${TvHomeSectionRowTagPrefix}library-$libraryId"
@Composable
fun TvContinueWatchingSection(
@@ -66,16 +76,16 @@ fun TvContinueWatchingSection(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Continue Watching",
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
TvHomeSectionRow(
modifier = modifier,
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
val progressFraction = (item.progress / 100.0).toFloat().coerceIn(0f, 1f)
@@ -135,16 +145,16 @@ fun TvNextUpSection(
onEpisodeSelected: (UUID, UUID, UUID) -> Unit,
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier
) {
if (items.isEmpty()) return
TvSectionHeader(
title = "Next Up",
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
TvHomeSectionRow(
modifier = modifier,
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
TvHomeLandscapeCard(
@@ -183,6 +193,7 @@ fun TvLibraryPosterSection(
onFocusedItem: (FocusableItem) -> Unit = {},
firstItemFocusRequester: FocusRequester? = null,
firstItemTestTag: String? = null,
rowTestTag: String? = null,
modifier: Modifier = Modifier,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
@@ -191,10 +202,9 @@ fun TvLibraryPosterSection(
TvSectionHeader(
title = title,
)
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing)
TvHomeSectionRow(
modifier = modifier,
rowTestTag = rowTestTag
) {
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
PosterCard(
@@ -227,6 +237,31 @@ fun TvLibraryPosterSection(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TvHomeSectionRow(
modifier: Modifier = Modifier,
rowTestTag: String? = null,
content: LazyListScope.() -> Unit,
) {
CompositionLocalProvider(LocalBringIntoViewSpec provides TvHomeRowBringIntoViewSpec) {
LazyRow(
modifier = modifier
.fillMaxWidth()
.then(
if (rowTestTag != null) {
Modifier.testTag(rowTestTag)
} else {
Modifier
}
),
contentPadding = PaddingValues(horizontal = TvHomeSectionsHorizontalPadding),
horizontalArrangement = Arrangement.spacedBy(TvHomeSectionsRowSpacing),
content = content
)
}
}
@Composable
fun TvSectionHeader(
title: String,