mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
Replace TV tabs with navigation drawer
This commit is contained in:
@@ -13,7 +13,6 @@ import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import hu.bbara.purefin.core.data.navigation.Route
|
||||
import hu.bbara.purefin.core.model.CastMember
|
||||
import hu.bbara.purefin.core.model.Episode
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
@@ -34,7 +33,6 @@ class EpisodeScreenContentTest {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = 63.0),
|
||||
seriesTitle = "Severance",
|
||||
topBarShortcut = episodeTopBarShortcut(Route.Home, onSeriesClick = {}),
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
@@ -60,16 +58,12 @@ class EpisodeScreenContentTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeScreenContent_hidesShortcut_whenNoShortcutIsProvided() {
|
||||
fun episodeScreenContent_hidesSeriesShortcut_whenShortcutUiIsUnavailable() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
EpisodeScreenContent(
|
||||
episode = sampleEpisode(progress = null),
|
||||
seriesTitle = "Severance",
|
||||
topBarShortcut = episodeTopBarShortcut(
|
||||
previousRoute = Route.PlayerRoute(mediaId = "episode-4"),
|
||||
onSeriesClick = {}
|
||||
),
|
||||
onBack = {},
|
||||
onPlay = {}
|
||||
)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class TvHomeTopBarTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun tvHomeTopBar_updatesSelectedTab() {
|
||||
var selectedTabIndex by mutableIntStateOf(1)
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvHomeTopBar(
|
||||
tabs = listOf(
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.SEARCH,
|
||||
label = "Search",
|
||||
icon = Icons.Outlined.Search
|
||||
),
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.HOME,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home
|
||||
)
|
||||
),
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
onTabSelected = { index, _ ->
|
||||
selectedTabIndex = index
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsSelected()
|
||||
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0")
|
||||
.assertIsNotSelected()
|
||||
.performClick()
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}0").assertIsSelected()
|
||||
composeRule.onNodeWithTag("${TvHomeTabTagPrefix}1").assertIsNotSelected()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class TvNavigationDrawerTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun tvDrawerHeader_onlyShowsTitleWhenExpanded() {
|
||||
var expanded by mutableStateOf(false)
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvDrawerHeader(expanded = expanded)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onAllNodesWithTag(TvDrawerTitleTag).assertCountEquals(0)
|
||||
|
||||
composeRule.runOnUiThread {
|
||||
expanded = true
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag(TvDrawerTitleTag).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tvNavigationDrawer_updatesSelectedDestination() {
|
||||
var selectedDestination by mutableStateOf(TvDrawerDestination.HOME)
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvNavigationDrawer(
|
||||
destinations = listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home
|
||||
),
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.LIBRARIES,
|
||||
label = "Libraries",
|
||||
icon = Icons.Outlined.Collections
|
||||
)
|
||||
),
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = { destination ->
|
||||
selectedDestination = destination
|
||||
}
|
||||
) {
|
||||
Box(modifier = Modifier.size(320.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}0").assertIsSelected()
|
||||
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}1")
|
||||
.assertIsNotSelected()
|
||||
.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
.performKeyInput {
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}1").assertIsSelected()
|
||||
composeRule.onNodeWithTag("${TvDrawerItemTagPrefix}0").assertIsNotSelected()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package hu.bbara.purefin.tv.library.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import hu.bbara.purefin.ui.theme.AppTheme
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class TvLibrariesOverviewScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun tvLibrariesOverviewScreen_opensSelectedLibrary() {
|
||||
val libraries = listOf(
|
||||
LibraryItem(
|
||||
id = UUID.fromString("11111111-1111-1111-1111-111111111111"),
|
||||
name = "Movies",
|
||||
type = CollectionType.MOVIES,
|
||||
posterUrl = "",
|
||||
isEmpty = false
|
||||
),
|
||||
LibraryItem(
|
||||
id = UUID.fromString("22222222-2222-2222-2222-222222222222"),
|
||||
name = "Shows",
|
||||
type = CollectionType.TVSHOWS,
|
||||
posterUrl = "",
|
||||
isEmpty = false
|
||||
)
|
||||
)
|
||||
var openedLibrary: LibraryItem? = null
|
||||
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TvLibrariesOverviewScreen(
|
||||
libraries = libraries,
|
||||
onLibrarySelected = { library ->
|
||||
openedLibrary = library
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag("${TvLibrariesOverviewItemTagPrefix}1").performClick()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals(libraries[1], openedLibrary)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,31 @@
|
||||
package hu.bbara.purefin.tv
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import hu.bbara.purefin.feature.shared.home.AppViewModel
|
||||
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
|
||||
import hu.bbara.purefin.tv.home.TvHomeScreen
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeTabDestination
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeTabItem
|
||||
import hu.bbara.purefin.tv.home.ui.TvHomeTopBar
|
||||
import hu.bbara.purefin.tv.library.ui.TvLibraryContent
|
||||
import hu.bbara.purefin.tv.home.ui.TvDrawerDestination
|
||||
import hu.bbara.purefin.tv.home.ui.TvDrawerDestinationItem
|
||||
import hu.bbara.purefin.tv.home.ui.TvNavigationDrawer
|
||||
import hu.bbara.purefin.tv.library.ui.TvLibrariesOverviewScreen
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
@Composable
|
||||
fun TvAppScreen(
|
||||
viewModel: AppViewModel = hiltViewModel(),
|
||||
libraryViewModel: LibraryViewModel = hiltViewModel(),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
@@ -40,46 +33,28 @@ fun TvAppScreen(
|
||||
val continueWatching by viewModel.continueWatching.collectAsState()
|
||||
val nextUp by viewModel.nextUp.collectAsState()
|
||||
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
|
||||
val selectedLibraryItems by libraryViewModel.contents.collectAsState()
|
||||
|
||||
var selectedTabIndex by remember { mutableIntStateOf(1) }
|
||||
var selectedDestination by rememberSaveable { androidx.compose.runtime.mutableStateOf(TvDrawerDestination.HOME) }
|
||||
|
||||
val tabs = remember(libraries) {
|
||||
buildList {
|
||||
add(
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.SETTINGS,
|
||||
label = "Settings",
|
||||
icon = Icons.Outlined.Settings,
|
||||
)
|
||||
)
|
||||
add(
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.SEARCH,
|
||||
label = "Search",
|
||||
icon = Icons.Outlined.Search,
|
||||
)
|
||||
)
|
||||
add(
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.HOME,
|
||||
val destinations = remember(libraries, selectedDestination) {
|
||||
listOf(
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.HOME,
|
||||
label = "Home",
|
||||
icon = Icons.Outlined.Home,
|
||||
)
|
||||
)
|
||||
addAll(libraries.map {
|
||||
TvHomeTabItem(
|
||||
destination = TvHomeTabDestination.LIBRARY,
|
||||
label = it.name,
|
||||
icon = when (it.type) {
|
||||
CollectionType.MOVIES -> Icons.Outlined.Movie
|
||||
CollectionType.TVSHOWS -> Icons.Outlined.Tv
|
||||
selected = selectedDestination == TvDrawerDestination.HOME
|
||||
),
|
||||
TvDrawerDestinationItem(
|
||||
destination = TvDrawerDestination.LIBRARIES,
|
||||
label = "Libraries",
|
||||
icon = when {
|
||||
libraries.any { it.type == CollectionType.MOVIES } -> Icons.Outlined.Movie
|
||||
libraries.any { it.type == CollectionType.TVSHOWS } -> Icons.Outlined.Tv
|
||||
else -> Icons.Outlined.Collections
|
||||
},
|
||||
libraryId = it.id
|
||||
selected = selectedDestination == TvDrawerDestination.LIBRARIES
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
@@ -87,41 +62,16 @@ fun TvAppScreen(
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, (tabs.size - 1).coerceAtLeast(0))
|
||||
val selectedTab = tabs.getOrNull(safeSelectedTabIndex)
|
||||
|
||||
LaunchedEffect(selectedTab?.destination, selectedTab?.libraryId) {
|
||||
if (selectedTab?.destination == TvHomeTabDestination.LIBRARY) {
|
||||
val libraryId = selectedTab.libraryId ?: return@LaunchedEffect
|
||||
libraryViewModel.selectLibrary(libraryId)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TvHomeTopBar(
|
||||
tabs = tabs,
|
||||
selectedTabIndex = safeSelectedTabIndex,
|
||||
onTabSelected = { index, _ ->
|
||||
selectedTabIndex = index
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
when (selectedTab?.destination) {
|
||||
TvHomeTabDestination.SETTINGS,
|
||||
TvHomeTabDestination.LIBRARY -> {
|
||||
TvLibraryContent(
|
||||
libraryItems = selectedLibraryItems,
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
TvHomeTabDestination.SEARCH,
|
||||
TvHomeTabDestination.HOME,
|
||||
null -> {
|
||||
TvNavigationDrawer(
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = { destination ->
|
||||
selectedDestination = destination
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
when (selectedDestination) {
|
||||
TvDrawerDestination.HOME -> {
|
||||
TvHomeScreen(
|
||||
libraries = libraries,
|
||||
libraryContent = latestLibraryContent,
|
||||
@@ -131,7 +81,16 @@ fun TvAppScreen(
|
||||
onMovieSelected = viewModel::onMovieSelected,
|
||||
onSeriesSelected = viewModel::onSeriesSelected,
|
||||
onEpisodeSelected = viewModel::onEpisodeSelected,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
TvDrawerDestination.LIBRARIES -> {
|
||||
TvLibrariesOverviewScreen(
|
||||
libraries = libraries,
|
||||
onLibrarySelected = { library ->
|
||||
viewModel.onLibrarySelected(library.id, library.name)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
object TvHomeMockData {
|
||||
val user = TvHomeUser(name = "Alex User", plan = "Premium Account")
|
||||
|
||||
val primaryNavItems = listOf(
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Home", icon = Icons.Outlined.Home, selected = true),
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Movies", icon = Icons.Outlined.Movie),
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "TV Shows", icon = Icons.Outlined.Tv),
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Search", icon = Icons.Outlined.Search)
|
||||
)
|
||||
|
||||
val secondaryNavItems = listOf(
|
||||
TvHomeNavItem(id = UUID.randomUUID(), label = "Settings", icon = Icons.Outlined.Settings)
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,15 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
||||
data class TvHomeNavItem(
|
||||
val id: UUID,
|
||||
enum class TvDrawerDestination {
|
||||
HOME,
|
||||
LIBRARIES
|
||||
}
|
||||
|
||||
data class TvDrawerDestinationItem(
|
||||
val destination: TvDrawerDestination,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val selected: Boolean = false
|
||||
)
|
||||
|
||||
enum class TvHomeTabDestination {
|
||||
SEARCH,
|
||||
HOME,
|
||||
LIBRARY,
|
||||
SETTINGS
|
||||
}
|
||||
|
||||
data class TvHomeTabItem(
|
||||
val destination: TvHomeTabDestination,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val libraryId: UUID? = null
|
||||
)
|
||||
|
||||
data class TvHomeUser(
|
||||
val name: String,
|
||||
val plan: String
|
||||
)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal const val TvHomeTabTagPrefix = "tv-home-tab-"
|
||||
private val TvHomeTopBarPillShape = RoundedCornerShape(18.dp)
|
||||
|
||||
@Composable
|
||||
fun TvHomeTopBar(
|
||||
tabs: List<TvHomeTabItem>,
|
||||
selectedTabIndex: Int,
|
||||
onTabSelected: (Int, TvHomeTabItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0))
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(safeSelectedTabIndex) {
|
||||
if (tabs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(safeSelectedTabIndex)
|
||||
}
|
||||
}
|
||||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(scheme.background),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
itemsIndexed(items = tabs) { index, tab ->
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val isSelected = index == safeSelectedTabIndex
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isSelected -> scheme.surfaceContainerHigh
|
||||
isFocused -> scheme.surfaceContainerHigh.copy(alpha = 0.92f)
|
||||
else -> scheme.surfaceContainer.copy(alpha = 0.72f)
|
||||
},
|
||||
label = "tv-home-tab-container"
|
||||
)
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (isSelected || isFocused) {
|
||||
scheme.onBackground
|
||||
} else {
|
||||
scheme.onSurfaceVariant
|
||||
},
|
||||
label = "tv-home-tab-content"
|
||||
)
|
||||
val borderColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
isFocused -> scheme.primary
|
||||
isSelected -> scheme.outline
|
||||
else -> scheme.outlineVariant.copy(alpha = 0.6f)
|
||||
},
|
||||
label = "tv-home-tab-border"
|
||||
)
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.03f else 1f,
|
||||
label = "tv-home-tab-scale"
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onTabSelected(index, tab) },
|
||||
modifier = Modifier
|
||||
.testTag("$TvHomeTabTagPrefix$index")
|
||||
.semantics { selected = isSelected }
|
||||
.heightIn(min = 46.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = TvHomeTopBarPillShape,
|
||||
border = BorderStroke(1.dp, borderColor),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = tab.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = tab.label,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package hu.bbara.purefin.tv.home.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.selected
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.DrawerValue
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.NavigationDrawer
|
||||
import androidx.tv.material3.NavigationDrawerItem
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.MaterialTheme as TvMaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
import hu.bbara.purefin.tv.R
|
||||
|
||||
internal const val TvDrawerItemTagPrefix = "tv-drawer-item-"
|
||||
internal const val TvDrawerTitleTag = "tv-drawer-title"
|
||||
|
||||
private val TvDrawerCollapsedWidth = 92.dp
|
||||
private val TvDrawerExpandedWidth = 280.dp
|
||||
|
||||
@Composable
|
||||
fun TvNavigationDrawer(
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: TvDrawerDestination,
|
||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
ProvideTvDrawerTheme {
|
||||
NavigationDrawer(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
drawerContent = {
|
||||
TvNavigationDrawerRail(
|
||||
drawerValue = if (hasFocus) DrawerValue.Open else DrawerValue.Closed,
|
||||
destinations = destinations,
|
||||
selectedDestination = selectedDestination,
|
||||
onDestinationSelected = onDestinationSelected
|
||||
)
|
||||
},
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProvideTvDrawerTheme(content: @Composable () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
|
||||
TvMaterialTheme(
|
||||
colorScheme = darkColorScheme(
|
||||
primary = scheme.primary,
|
||||
onPrimary = scheme.onPrimary,
|
||||
primaryContainer = scheme.primaryContainer,
|
||||
onPrimaryContainer = scheme.onPrimaryContainer,
|
||||
inversePrimary = scheme.inversePrimary,
|
||||
secondary = scheme.secondary,
|
||||
onSecondary = scheme.onSecondary,
|
||||
secondaryContainer = scheme.secondaryContainer,
|
||||
onSecondaryContainer = scheme.onSecondaryContainer,
|
||||
tertiary = scheme.tertiary,
|
||||
onTertiary = scheme.onTertiary,
|
||||
tertiaryContainer = scheme.tertiaryContainer,
|
||||
onTertiaryContainer = scheme.onTertiaryContainer,
|
||||
background = scheme.background,
|
||||
onBackground = scheme.onBackground,
|
||||
surface = scheme.surface,
|
||||
onSurface = scheme.onSurface,
|
||||
surfaceVariant = scheme.surfaceVariant,
|
||||
onSurfaceVariant = scheme.onSurfaceVariant,
|
||||
surfaceTint = scheme.surfaceTint,
|
||||
inverseSurface = scheme.inverseSurface,
|
||||
inverseOnSurface = scheme.inverseOnSurface,
|
||||
error = scheme.error,
|
||||
onError = scheme.onError,
|
||||
errorContainer = scheme.errorContainer,
|
||||
onErrorContainer = scheme.onErrorContainer,
|
||||
border = scheme.outline,
|
||||
borderVariant = scheme.outlineVariant,
|
||||
scrim = scheme.scrim
|
||||
),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvDrawerHeader(
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.purefin_logo_foreground),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = "Purefin",
|
||||
modifier = Modifier.testTag(TvDrawerTitleTag)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun androidx.tv.material3.NavigationDrawerScope.TvNavigationDrawerRail(
|
||||
drawerValue: DrawerValue,
|
||||
destinations: List<TvDrawerDestinationItem>,
|
||||
selectedDestination: TvDrawerDestination,
|
||||
onDestinationSelected: (TvDrawerDestination) -> Unit,
|
||||
) {
|
||||
val expanded = drawerValue == DrawerValue.Open
|
||||
val drawerWidth = animateDpAsState(
|
||||
targetValue = if (expanded) TvDrawerExpandedWidth else TvDrawerCollapsedWidth,
|
||||
label = "tv-drawer-width"
|
||||
)
|
||||
val scheme = androidx.tv.material3.MaterialTheme.colorScheme
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(drawerWidth.value)
|
||||
.fillMaxHeight()
|
||||
.background(scheme.surface.copy(alpha = 0.96f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
TvDrawerHeader(
|
||||
expanded = expanded,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
)
|
||||
destinations.forEachIndexed { index, destination ->
|
||||
val isSelected = destination.destination == selectedDestination
|
||||
NavigationDrawerItem(
|
||||
selected = isSelected,
|
||||
onClick = { onDestinationSelected(destination.destination) },
|
||||
modifier = Modifier
|
||||
.testTag("$TvDrawerItemTagPrefix$index")
|
||||
.semantics { selected = isSelected },
|
||||
leadingContent = {
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = destination.label
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (expanded) {
|
||||
Text(
|
||||
text = destination.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package hu.bbara.purefin.tv.library.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.Movie
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import hu.bbara.purefin.common.ui.components.PurefinAsyncImage
|
||||
import hu.bbara.purefin.feature.shared.home.LibraryItem
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
internal const val TvLibrariesOverviewItemTagPrefix = "tv-libraries-overview-item-"
|
||||
|
||||
private val TvLibrariesOverviewCardShape = RoundedCornerShape(18.dp)
|
||||
|
||||
@Composable
|
||||
fun TvLibrariesOverviewScreen(
|
||||
libraries: List<LibraryItem>,
|
||||
onLibrarySelected: (LibraryItem) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val firstItemFocusRequester = remember { FocusRequester() }
|
||||
var initialFocusApplied by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(libraries.size, initialFocusApplied) {
|
||||
if (!initialFocusApplied && libraries.isNotEmpty()) {
|
||||
firstItemFocusRequester.requestFocus()
|
||||
initialFocusApplied = true
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(scheme.background)
|
||||
.padding(top = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Libraries",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = scheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 28.dp)
|
||||
)
|
||||
if (libraries.isEmpty()) {
|
||||
Text(
|
||||
text = "No libraries available",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = scheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 16.dp)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 220.dp),
|
||||
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
itemsIndexed(libraries, key = { _, item -> item.id }) { index, item ->
|
||||
TvLibraryOverviewCard(
|
||||
item = item,
|
||||
onClick = { onLibrarySelected(item) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (index == 0) {
|
||||
Modifier.focusRequester(firstItemFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.testTag("$TvLibrariesOverviewItemTagPrefix$index")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvLibraryOverviewCard(
|
||||
item: LibraryItem,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isFocused) 1.05f else 1f,
|
||||
label = "tv-library-overview-scale"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(TvLibrariesOverviewCardShape)
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = if (isFocused) scheme.primary else scheme.outlineVariant.copy(alpha = 0.45f),
|
||||
shape = TvLibrariesOverviewCardShape
|
||||
)
|
||||
.background(scheme.surfaceVariant)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
if (item.posterUrl.isNotBlank()) {
|
||||
PurefinAsyncImage(
|
||||
model = item.posterUrl,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = when (item.type) {
|
||||
CollectionType.MOVIES -> Icons.Outlined.Movie
|
||||
CollectionType.TVSHOWS -> Icons.Outlined.Tv
|
||||
else -> Icons.Outlined.Collections
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = scheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = scheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Text(
|
||||
text = if (item.isEmpty) "Empty library" else "Open library",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user