feat: refactor discover content handling and move to separate file

This commit is contained in:
tapframe 2026-03-11 22:20:12 +05:30
parent 0b5d4d2816
commit 160e0a8c47
2 changed files with 396 additions and 356 deletions

View file

@ -0,0 +1,389 @@
package com.nuvio.app.features.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.components.HomeEmptyStateCard
internal fun LazyListScope.discoverContent(
state: DiscoverUiState,
onTypeSelected: (String) -> Unit,
onCatalogSelected: (String) -> Unit,
onGenreSelected: (String?) -> Unit,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
item {
DiscoverSectionHeader(modifier = Modifier.padding(horizontal = 16.dp))
}
item {
DiscoverFilterRow(
state = state,
modifier = Modifier.padding(horizontal = 16.dp),
onTypeSelected = onTypeSelected,
onCatalogSelected = onCatalogSelected,
onGenreSelected = onGenreSelected,
)
}
state.selectedCatalog?.let { selectedCatalog ->
item {
Text(
text = "${selectedCatalog.addonName}${selectedCatalog.type.displayTypeLabel()}",
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
when {
state.isLoading && state.items.isEmpty() -> {
items(2) {
DiscoverSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
state.items.isEmpty() -> {
item {
DiscoverEmptyStateCard(
reason = state.emptyStateReason,
errorMessage = state.errorMessage,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
else -> {
items(state.items.chunked(3)) { rowItems ->
DiscoverGridRow(
items = rowItems,
modifier = Modifier.padding(horizontal = 16.dp),
onPosterClick = onPosterClick,
)
}
if (state.isLoading) {
item {
CatalogLoadingFooter(
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
}
}
@Composable
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
Text(
text = "Discover",
modifier = modifier,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
@Composable
private fun DiscoverFilterRow(
state: DiscoverUiState,
onTypeSelected: (String) -> Unit,
onCatalogSelected: (String) -> Unit,
onGenreSelected: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
DiscoverDropdownChip(
label = state.selectedType?.displayTypeLabel() ?: "Type",
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
enabled = state.typeOptions.isNotEmpty(),
onSelected = { onTypeSelected(it.key) },
)
DiscoverDropdownChip(
label = state.selectedCatalog?.catalogName ?: "Catalog",
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
enabled = state.catalogOptions.isNotEmpty(),
onSelected = { onCatalogSelected(it.key) },
)
val selectedCatalog = state.selectedCatalog
val genreOptions = buildList {
if (selectedCatalog?.genreRequired != true) {
add(DiscoverOptionItem(key = "", label = "All Genres"))
}
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
}
DiscoverDropdownChip(
label = state.selectedGenre ?: "All Genres",
options = genreOptions,
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
onSelected = { option ->
onGenreSelected(option.key.ifBlank { null })
},
)
}
}
@Composable
private fun DiscoverDropdownChip(
label: String,
options: List<DiscoverOptionItem>,
enabled: Boolean,
onSelected: (DiscoverOptionItem) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surface)
.then(
if (enabled) {
Modifier.clickable { expanded = true }
} else {
Modifier
},
)
.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
expanded = false
onSelected(option)
},
)
}
}
}
}
@Composable
private fun DiscoverGridRow(
items: List<MetaPreview>,
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items.forEach { item ->
DiscoverPosterTile(
item = item,
modifier = Modifier.weight(1f),
onClick = onPosterClick?.let { { it(item) } },
)
}
repeat(3 - items.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun DiscoverPosterTile(
item: MetaPreview,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(item.posterShape.discoverAspectRatio())
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
) {
if (item.poster != null) {
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" }
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun DiscoverSkeletonRow(modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(3) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(0.68f)
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
)
}
}
}
@Composable
private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.dp,
)
}
}
@Composable
private fun DiscoverEmptyStateCard(
reason: DiscoverEmptyStateReason?,
errorMessage: String?,
modifier: Modifier = Modifier,
) {
val title: String
val message: String
when (reason) {
DiscoverEmptyStateReason.NoActiveAddons -> {
title = "No active addons"
message = "Install and validate at least one addon before browsing discover catalogs."
}
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
title = "No discover catalogs"
message = "Installed addons do not expose board-compatible catalogs for discover."
}
DiscoverEmptyStateReason.RequestFailed -> {
title = "Could not load discover"
message = errorMessage ?: "The selected catalog failed to return discover items."
}
DiscoverEmptyStateReason.NoResults, null -> {
title = "No titles found"
message = "The selected catalog and filters did not return any items."
}
}
HomeEmptyStateCard(
modifier = modifier,
title = title,
message = message,
)
}
private data class DiscoverOptionItem(
val key: String,
val label: String,
)
private fun String.displayTypeLabel(): String =
when (lowercase()) {
"movie" -> "Movies"
"series" -> "Series"
"anime" -> "Anime"
"channel" -> "Channels"
"tv" -> "TV"
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
private fun PosterShape.discoverAspectRatio(): Float =
when (this) {
PosterShape.Poster -> 0.68f
PosterShape.Square -> 1f
PosterShape.Landscape -> 1.2f
}

View file

@ -1,32 +1,19 @@
package com.nuvio.app.features.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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
@ -36,23 +23,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
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.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.home.components.HomeSkeletonRow
@ -147,66 +126,13 @@ fun SearchScreen(
}
if (query.isBlank()) {
item {
DiscoverSectionHeader(modifier = Modifier.padding(horizontal = 16.dp))
}
item {
DiscoverFilterRow(
state = discoverUiState,
modifier = Modifier.padding(horizontal = 16.dp),
onTypeSelected = SearchRepository::selectDiscoverType,
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
onGenreSelected = SearchRepository::selectDiscoverGenre,
)
}
discoverUiState.selectedCatalog?.let { selectedCatalog ->
item {
Text(
text = "${selectedCatalog.addonName}${selectedCatalog.type.displayTypeLabel()}",
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
when {
discoverUiState.isLoading && discoverUiState.items.isEmpty() -> {
items(2) {
DiscoverSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
discoverUiState.items.isEmpty() -> {
item {
DiscoverEmptyStateCard(
reason = discoverUiState.emptyStateReason,
errorMessage = discoverUiState.errorMessage,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
else -> {
items(discoverUiState.items.chunked(3)) { rowItems ->
DiscoverGridRow(
items = rowItems,
modifier = Modifier.padding(horizontal = 16.dp),
onPosterClick = onPosterClick,
)
}
if (discoverUiState.isLoading) {
item {
CatalogLoadingFooter(
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
}
discoverContent(
state = discoverUiState,
onTypeSelected = SearchRepository::selectDiscoverType,
onCatalogSelected = SearchRepository::selectDiscoverCatalog,
onGenreSelected = SearchRepository::selectDiscoverGenre,
onPosterClick = onPosterClick,
)
} else {
when {
uiState.isLoading && uiState.sections.isEmpty() -> {
@ -260,259 +186,6 @@ fun SearchScreen(
}
}
@Composable
private fun DiscoverSectionHeader(modifier: Modifier = Modifier) {
Text(
text = "Discover",
modifier = modifier,
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
@Composable
private fun DiscoverFilterRow(
state: DiscoverUiState,
onTypeSelected: (String) -> Unit,
onCatalogSelected: (String) -> Unit,
onGenreSelected: (String?) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
DiscoverDropdownChip(
label = state.selectedType?.displayTypeLabel() ?: "Type",
options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) },
enabled = state.typeOptions.isNotEmpty(),
onSelected = { onTypeSelected(it.key) },
)
DiscoverDropdownChip(
label = state.selectedCatalog?.catalogName ?: "Catalog",
options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) },
enabled = state.catalogOptions.isNotEmpty(),
onSelected = { onCatalogSelected(it.key) },
)
val selectedCatalog = state.selectedCatalog
val genreOptions = buildList {
if (selectedCatalog?.genreRequired != true) {
add(DiscoverOptionItem(key = "", label = "All Genres"))
}
addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) })
}
DiscoverDropdownChip(
label = state.selectedGenre ?: "All Genres",
options = genreOptions,
enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true,
onSelected = { option ->
onGenreSelected(option.key.ifBlank { null })
},
)
}
}
@Composable
private fun DiscoverDropdownChip(
label: String,
options: List<DiscoverOptionItem>,
enabled: Boolean,
onSelected: (DiscoverOptionItem) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surface)
.then(
if (enabled) {
Modifier.clickable { expanded = true }
} else {
Modifier
},
)
.padding(horizontal = 18.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
androidx.compose.material3.Icon(
imageVector = Icons.Rounded.KeyboardArrowDown,
contentDescription = null,
tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline,
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option.label) },
onClick = {
expanded = false
onSelected(option)
},
)
}
}
}
}
@Composable
private fun DiscoverGridRow(
items: List<MetaPreview>,
modifier: Modifier = Modifier,
onPosterClick: ((MetaPreview) -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items.forEach { item ->
DiscoverPosterTile(
item = item,
modifier = Modifier.weight(1f),
onClick = onPosterClick?.let { { it(item) } },
)
}
repeat(3 - items.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun DiscoverPosterTile(
item: MetaPreview,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
Column(
modifier = modifier.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(item.posterShape.discoverAspectRatio())
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
) {
if (item.poster != null) {
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
Text(
text = item.name,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" }
if (detail != null) {
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@Composable
private fun DiscoverSkeletonRow(modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(3) {
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(0.68f)
.clip(RoundedCornerShape(22.dp))
.background(MaterialTheme.colorScheme.surface),
)
}
}
}
@Composable
private fun CatalogLoadingFooter(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 2.dp,
)
}
}
@Composable
private fun DiscoverEmptyStateCard(
reason: DiscoverEmptyStateReason?,
errorMessage: String?,
modifier: Modifier = Modifier,
) {
val title: String
val message: String
when (reason) {
DiscoverEmptyStateReason.NoActiveAddons -> {
title = "No active addons"
message = "Install and validate at least one addon before browsing discover catalogs."
}
DiscoverEmptyStateReason.NoDiscoverCatalogs -> {
title = "No discover catalogs"
message = "Installed addons do not expose board-compatible catalogs for discover."
}
DiscoverEmptyStateReason.RequestFailed -> {
title = "Could not load discover"
message = errorMessage ?: "The selected catalog failed to return discover items."
}
DiscoverEmptyStateReason.NoResults, null -> {
title = "No titles found"
message = "The selected catalog and filters did not return any items."
}
}
HomeEmptyStateCard(
modifier = modifier,
title = title,
message = message,
)
}
@Composable
private fun SearchEmptyStateCard(
reason: SearchEmptyStateReason?,
@ -550,25 +223,3 @@ private fun SearchEmptyStateCard(
message = message,
)
}
private data class DiscoverOptionItem(
val key: String,
val label: String,
)
private fun String.displayTypeLabel(): String =
when (lowercase()) {
"movie" -> "Movies"
"series" -> "Series"
"anime" -> "Anime"
"channel" -> "Channels"
"tv" -> "TV"
else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
private fun PosterShape.discoverAspectRatio(): Float =
when (this) {
PosterShape.Poster -> 0.68f
PosterShape.Square -> 1f
PosterShape.Landscape -> 1.2f
}