Change Navigation Drawler to Top Tabs

This commit is contained in:
2026-03-28 10:50:53 +01:00
parent 4c5dc8a452
commit d1ef218b16
6 changed files with 268 additions and 85 deletions

View File

@@ -2,102 +2,124 @@ package hu.bbara.purefin.tv.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.Tv
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.ui.TvHomeContent
import hu.bbara.purefin.tv.home.ui.TvHomeDrawerContent
import hu.bbara.purefin.tv.home.ui.TvHomeMockData
import hu.bbara.purefin.tv.home.ui.TvHomeNavItem
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 kotlinx.coroutines.launch
import hu.bbara.purefin.tv.library.ui.TvLibraryContent
import org.jellyfin.sdk.model.api.CollectionType
@Composable
fun TvHomePage(
viewModel: AppViewModel = hiltViewModel(),
libraryViewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
val drawerState = rememberDrawerState(DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
var selectedTabIndex by remember { mutableIntStateOf(1) }
val libraries by viewModel.libraries.collectAsState()
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
val continueWatching by viewModel.continueWatching.collectAsState()
val nextUp by viewModel.nextUp.collectAsState()
val latestLibraryContent by viewModel.latestLibraryContent.collectAsState()
val selectedLibraryItems by libraryViewModel.contents.collectAsState()
val libraries = viewModel.libraries.collectAsState().value
val isOfflineMode = viewModel.isOfflineMode.collectAsState().value
val libraryNavItems = libraries.map {
TvHomeNavItem(
id = it.id,
val tabs = remember(libraries) {
buildList {
add(
TvHomeTabItem(
destination = TvHomeTabDestination.SEARCH,
label = "Search",
icon = Icons.Outlined.Search,
)
)
add(
TvHomeTabItem(
destination = TvHomeTabDestination.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
else -> Icons.Outlined.Collections
},
libraryId = it.id
)
})
}
}
val continueWatching = viewModel.continueWatching.collectAsState()
val nextUp = viewModel.nextUp.collectAsState()
val latestLibraryContent = viewModel.latestLibraryContent.collectAsState()
LifecycleResumeEffect(Unit) {
viewModel.onResumed()
onPauseOrDispose { }
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
modifier = Modifier
.width(280.dp)
.fillMaxSize(),
drawerContainerColor = MaterialTheme.colorScheme.surface,
drawerContentColor = MaterialTheme.colorScheme.onBackground
) {
TvHomeDrawerContent(
title = "Jellyfin",
subtitle = "Library Dashboard",
primaryNavItems = libraryNavItems,
secondaryNavItems = TvHomeMockData.secondaryNavItems,
user = TvHomeMockData.user,
onLibrarySelected = { item -> viewModel.onLibrarySelected(item.id, item.label) },
onLogout = viewModel::logout
)
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(),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = {
TvHomeTopBar(
onMenuClick = { coroutineScope.launch { drawerState.open() } },
tabs = tabs,
selectedTabIndex = safeSelectedTabIndex,
onTabSelected = { index, _ ->
selectedTabIndex = index
},
isOfflineMode = isOfflineMode,
onToggleOfflineMode = viewModel::toggleOfflineMode
)
}
) { innerPadding ->
when (selectedTab?.destination) {
TvHomeTabDestination.LIBRARY -> {
TvLibraryContent(
libraryItems = selectedLibraryItems,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
modifier = Modifier.padding(innerPadding)
)
}
TvHomeTabDestination.SEARCH,
TvHomeTabDestination.HOME,
null -> {
TvHomeContent(
libraries = libraries,
libraryContent = latestLibraryContent.value,
continueWatching = continueWatching.value,
nextUp = nextUp.value,
libraryContent = latestLibraryContent,
continueWatching = continueWatching,
nextUp = nextUp,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
onEpisodeSelected = viewModel::onEpisodeSelected,
@@ -105,4 +127,5 @@ fun TvHomePage(
)
}
}
}
}

View File

@@ -10,6 +10,19 @@ data class TvHomeNavItem(
val selected: Boolean = false
)
enum class TvHomeTabDestination {
SEARCH,
HOME,
LIBRARY
}
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

View File

@@ -10,24 +10,31 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Cloud
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.common.ui.components.SearchField
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TvHomeTopBar(
onMenuClick: () -> Unit,
tabs: List<TvHomeTabItem>,
selectedTabIndex: Int,
onTabSelected: (Int, TvHomeTabItem) -> Unit,
isOfflineMode: Boolean,
onToggleOfflineMode: () -> Unit,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val safeSelectedTabIndex = selectedTabIndex.coerceIn(0, tabs.lastIndex.coerceAtLeast(0))
Box(
modifier = modifier
@@ -41,19 +48,33 @@ fun TvHomeTopBar(
.padding(horizontal = 16.dp, vertical = 16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
PurefinIconButton(
icon = Icons.Outlined.Menu,
contentDescription = "Menu",
onClick = onMenuClick,
PrimaryScrollableTabRow(
selectedTabIndex = safeSelectedTabIndex,
modifier = Modifier.weight(1f),
containerColor = scheme.surface,
contentColor = scheme.onSurface
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = index == safeSelectedTabIndex,
onClick = { onTabSelected(index, tab) },
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = tab.icon,
contentDescription = null
)
SearchField(
value = "",
onValueChange = {},
placeholder = "Search",
modifier = Modifier.weight(1.0f, true),
Text(text = tab.label)
}
}
)
}
}
PurefinIconButton(
icon = if (isOfflineMode) Icons.Outlined.CloudOff else Icons.Outlined.Cloud,
contentDescription = if (isOfflineMode) "Switch to Online" else "Switch to Offline",

View File

@@ -0,0 +1,113 @@
package hu.bbara.purefin.tv.library.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.common.ui.PosterCard
import hu.bbara.purefin.common.ui.components.PurefinIconButton
import hu.bbara.purefin.core.data.navigation.LibraryDto
import hu.bbara.purefin.feature.shared.home.PosterItem
import hu.bbara.purefin.feature.shared.library.LibraryViewModel
import org.jellyfin.sdk.model.UUID
@Composable
fun TvLibraryScreen(
library: LibraryDto,
viewModel: LibraryViewModel = hiltViewModel(),
modifier: Modifier = Modifier
) {
LaunchedEffect(library) {
viewModel.selectLibrary(libraryId = library.id)
}
val libraryItems = viewModel.contents.collectAsState()
Scaffold(
modifier = modifier,
topBar = {
TvLibraryTopBar(
title = library.name,
onBack = viewModel::onBack
)
}
) { innerPadding ->
TvLibraryContent(
libraryItems = libraryItems.value,
onMovieSelected = viewModel::onMovieSelected,
onSeriesSelected = viewModel::onSeriesSelected,
modifier = Modifier.padding(innerPadding)
)
}
}
@Composable
private fun TvLibraryTopBar(
title: String,
onBack: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PurefinIconButton(
icon = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back",
onClick = onBack
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = 12.dp)
)
}
}
@Composable
fun TvLibraryContent(
libraryItems: List<PosterItem>,
onMovieSelected: (UUID) -> Unit,
onSeriesSelected: (UUID) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.background(MaterialTheme.colorScheme.background)
) {
items(libraryItems) { item ->
PosterCard(
item = item,
onMovieSelected = onMovieSelected,
onSeriesSelected = onSeriesSelected,
onEpisodeSelected = { _, _, _ -> }
)
}
}
}
}

View File

@@ -47,4 +47,10 @@ object TvNavigationModule {
fun provideTvPlayerEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvPlayerSection()
}
@IntoSet
@Provides
fun provideTvLibraryEntryBuilder(): EntryProviderScope<Route>.() -> Unit = {
tvLibrarySection()
}
}

View File

@@ -8,6 +8,7 @@ import hu.bbara.purefin.core.data.navigation.LocalNavigationManager
import hu.bbara.purefin.core.data.navigation.Route
import hu.bbara.purefin.login.ui.LoginScreen
import hu.bbara.purefin.tv.home.TvHomePage
import hu.bbara.purefin.tv.library.ui.TvLibraryScreen
import hu.bbara.purefin.tv.player.TvPlayerScreen
fun EntryProviderScope<Route>.tvHomeSection() {
@@ -49,3 +50,9 @@ fun EntryProviderScope<Route>.tvPlayerSection() {
)
}
}
fun EntryProviderScope<Route>.tvLibrarySection() {
entry<Route.LibraryRoute> { route ->
TvLibraryScreen(library = route.library)
}
}