mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: Add local filters to Trakt Library view all screen
- Added "All", "Movies", and "Series" FilterChips to the `CatalogHeader`. - Computed `filteredItems` to apply filtering for the Trakt Library screen based on the item type (movies vs series/tv). - Applied Material Theme `FilterChipDefaults` properties to ensure accurate theming.
This commit is contained in:
parent
d328d62c66
commit
b73cccc43b
5 changed files with 630 additions and 3 deletions
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
|
@ -21,6 +22,8 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -47,6 +50,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
import com.nuvio.app.core.ui.NuvioBackButton
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
|
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
||||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
import com.nuvio.app.core.ui.posterCardClickable
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
|
|
@ -80,6 +84,15 @@ fun CatalogScreen(
|
||||||
var headerHeightPx by remember { mutableIntStateOf(0) }
|
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||||
var observedOfflineState by remember { mutableStateOf(false) }
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val isTraktLibrary = manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL && subtitle.contains("Trakt", ignoreCase = true)
|
||||||
|
var selectedFilter by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val filteredItems = remember(uiState.items, selectedFilter) {
|
||||||
|
if (selectedFilter == 1) uiState.items.filter { it.type.lowercase() in listOf("movie", "film") }
|
||||||
|
else if (selectedFilter == 2) uiState.items.filter { it.type.lowercase() in listOf("series", "tv", "show") }
|
||||||
|
else uiState.items
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||||
CatalogRepository.load(
|
CatalogRepository.load(
|
||||||
manifestUrl = manifestUrl,
|
manifestUrl = manifestUrl,
|
||||||
|
|
@ -152,11 +165,11 @@ fun CatalogScreen(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
) {
|
) {
|
||||||
if (uiState.items.isEmpty() && uiState.isLoading) {
|
if (filteredItems.isEmpty() && uiState.isLoading) {
|
||||||
items(columns * 3) {
|
items(columns * 3) {
|
||||||
CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp)
|
CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp)
|
||||||
}
|
}
|
||||||
} else if (uiState.items.isEmpty()) {
|
} else if (filteredItems.isEmpty()) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
CatalogEmptyState(
|
CatalogEmptyState(
|
||||||
errorMessage = uiState.errorMessage,
|
errorMessage = uiState.errorMessage,
|
||||||
|
|
@ -176,7 +189,7 @@ fun CatalogScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(
|
items(
|
||||||
items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
items = filteredItems.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||||
key = { item -> item.lazyKey },
|
key = { item -> item.lazyKey },
|
||||||
) { keyedItem ->
|
) { keyedItem ->
|
||||||
val item = keyedItem.value
|
val item = keyedItem.value
|
||||||
|
|
@ -198,6 +211,9 @@ fun CatalogScreen(
|
||||||
CatalogHeader(
|
CatalogHeader(
|
||||||
title = title,
|
title = title,
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
|
showFilters = isTraktLibrary,
|
||||||
|
selectedFilter = selectedFilter,
|
||||||
|
onFilterSelected = { selectedFilter = it },
|
||||||
modifier = Modifier.onSizeChanged { headerHeightPx = it.height },
|
modifier = Modifier.onSizeChanged { headerHeightPx = it.height },
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
|
|
@ -209,6 +225,9 @@ fun CatalogScreen(
|
||||||
private fun CatalogHeader(
|
private fun CatalogHeader(
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
|
showFilters: Boolean = false,
|
||||||
|
selectedFilter: Int = 0,
|
||||||
|
onFilterSelected: (Int) -> Unit = {},
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -246,6 +265,41 @@ private fun CatalogHeader(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showFilters) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == 0,
|
||||||
|
onClick = { onFilterSelected(0) },
|
||||||
|
label = { Text(stringResource(Res.string.collections_tab_all)) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == 1,
|
||||||
|
onClick = { onFilterSelected(1) },
|
||||||
|
label = { Text(stringResource(Res.string.media_movies)) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == 2,
|
||||||
|
onClick = { onFilterSelected(2) },
|
||||||
|
label = { Text(stringResource(Res.string.media_series)) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,431 @@
|
||||||
|
package com.nuvio.app.features.catalog
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
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.Row
|
||||||
|
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.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.nuvio.app.core.network.NetworkCondition
|
||||||
|
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||||
|
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
|
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||||
|
import com.nuvio.app.features.home.MetaPreview
|
||||||
|
import com.nuvio.app.features.home.PosterShape
|
||||||
|
import com.nuvio.app.features.home.stableKey
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import nuvio.composeapp.generated.resources.*
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CatalogScreen(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
manifestUrl: String,
|
||||||
|
type: String,
|
||||||
|
catalogId: String,
|
||||||
|
supportsPagination: Boolean,
|
||||||
|
genre: String? = null,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||||
|
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||||
|
val gridState = rememberLazyGridState()
|
||||||
|
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val isTraktLibrary = manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL && subtitle.contains("Trakt", ignoreCase = true)
|
||||||
|
var selectedFilter by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val filteredItems = remember(uiState.items, selectedFilter) {
|
||||||
|
if (selectedFilter == 1) uiState.items.filter { it.type.lowercase() in listOf("movie", "film") }
|
||||||
|
else if (selectedFilter == 2) uiState.items.filter { it.type.lowercase() in listOf("series", "tv", "show") }
|
||||||
|
else uiState.items
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||||
|
CatalogRepository.load(
|
||||||
|
manifestUrl = manifestUrl,
|
||||||
|
type = type,
|
||||||
|
catalogId = catalogId,
|
||||||
|
genre = genre,
|
||||||
|
supportsPagination = supportsPagination,
|
||||||
|
force = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(gridState, uiState.canLoadMore, uiState.isLoading) {
|
||||||
|
snapshotFlow { gridState.layoutInfo }
|
||||||
|
.map { layoutInfo ->
|
||||||
|
val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
lastVisible >= layoutInfo.totalItemsCount - 6
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it && uiState.canLoadMore && !uiState.isLoading }
|
||||||
|
.collect {
|
||||||
|
CatalogRepository.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||||
|
when (networkStatusUiState.condition) {
|
||||||
|
NetworkCondition.NoInternet,
|
||||||
|
NetworkCondition.ServersUnreachable,
|
||||||
|
-> {
|
||||||
|
observedOfflineState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkCondition.Online -> {
|
||||||
|
if (!observedOfflineState) return@LaunchedEffect
|
||||||
|
observedOfflineState = false
|
||||||
|
CatalogRepository.load(
|
||||||
|
manifestUrl = manifestUrl,
|
||||||
|
type = type,
|
||||||
|
catalogId = catalogId,
|
||||||
|
genre = genre,
|
||||||
|
supportsPagination = supportsPagination,
|
||||||
|
force = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkCondition.Unknown,
|
||||||
|
NetworkCondition.Checking,
|
||||||
|
-> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
val columns = remember(maxWidth) { catalogGridColumnsForWidth(maxWidth) }
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(columns),
|
||||||
|
state = gridState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
top = with(androidx.compose.ui.platform.LocalDensity.current) { headerHeightPx.toDp() } + 12.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
bottom = nuvioSafeBottomPadding(28.dp),
|
||||||
|
),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
if (filteredItems.isEmpty() && uiState.isLoading) {
|
||||||
|
items(columns * 3) {
|
||||||
|
CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp)
|
||||||
|
}
|
||||||
|
} else if (filteredItems.isEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
CatalogEmptyState(
|
||||||
|
errorMessage = uiState.errorMessage,
|
||||||
|
networkCondition = networkStatusUiState.condition,
|
||||||
|
onRetry = {
|
||||||
|
NetworkStatusRepository.requestRefresh(force = true)
|
||||||
|
CatalogRepository.load(
|
||||||
|
manifestUrl = manifestUrl,
|
||||||
|
type = type,
|
||||||
|
catalogId = catalogId,
|
||||||
|
genre = genre,
|
||||||
|
supportsPagination = supportsPagination,
|
||||||
|
force = true,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(
|
||||||
|
items = filteredItems.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||||
|
key = { item -> item.lazyKey },
|
||||||
|
) { keyedItem ->
|
||||||
|
val item = keyedItem.value
|
||||||
|
CatalogPosterTile(
|
||||||
|
item = item,
|
||||||
|
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
||||||
|
hideLabels = posterCardStyle.hideLabelsEnabled,
|
||||||
|
onClick = onPosterClick?.let { { it(item) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (uiState.isLoading) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
CatalogLoadingFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CatalogHeader(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
showFilters = isTraktLibrary,
|
||||||
|
selectedFilter = selectedFilter,
|
||||||
|
onFilterSelected = { selectedFilter = it },
|
||||||
|
modifier = Modifier.onSizeChanged { headerHeightPx = it.height },
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CatalogHeader(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
showFilters: Boolean = false,
|
||||||
|
selectedFilter: Int = 0,
|
||||||
|
onFilterSelected: (Int) -> Unit = {},
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 52.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
NuvioBackButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
iconSize = 24.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showFilters) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == 0,
|
||||||
|
onClick = { onFilterSelected(0) },
|
||||||
|
label = { Text(stringResource(Res.string.collections_tab_all)) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == 1,
|
||||||
|
onClick = { onFilterSelected(1) },
|
||||||
|
label = { Text(stringResource(Res.string.media_movies)) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == 2,
|
||||||
|
onClick = { onFilterSelected(2) },
|
||||||
|
label = { Text(stringResource(Res.string.media_series)) },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CatalogPosterTile(
|
||||||
|
item: MetaPreview,
|
||||||
|
cornerRadiusDp: Int,
|
||||||
|
hideLabels: Boolean,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(item.posterShape.catalogAspectRatio())
|
||||||
|
.clip(RoundedCornerShape(cornerRadiusDp.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.posterCardClickable(onClick = onClick, onLongClick = null),
|
||||||
|
) {
|
||||||
|
if (item.poster != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.poster,
|
||||||
|
contentDescription = item.name,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hideLabels) {
|
||||||
|
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?.let { formatReleaseDateForDisplay(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 CatalogSkeletonTile(cornerRadiusDp: Int) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(0.68f)
|
||||||
|
.clip(RoundedCornerShape(cornerRadiusDp.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CatalogEmptyState(
|
||||||
|
errorMessage: String?,
|
||||||
|
networkCondition: NetworkCondition,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
if (networkCondition == NetworkCondition.NoInternet || networkCondition == NetworkCondition.ServersUnreachable) {
|
||||||
|
NuvioNetworkOfflineCard(
|
||||||
|
condition = networkCondition,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(Res.string.catalog_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = errorMessage ?: stringResource(Res.string.catalog_empty_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CatalogLoadingFooter() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PosterShape.catalogAspectRatio(): Float =
|
||||||
|
when (this) {
|
||||||
|
PosterShape.Poster -> 0.68f
|
||||||
|
PosterShape.Square -> 1f
|
||||||
|
PosterShape.Landscape -> 1.2f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun catalogGridColumnsForWidth(screenWidth: Dp): Int =
|
||||||
|
when {
|
||||||
|
screenWidth >= 1400.dp -> 7
|
||||||
|
screenWidth >= 1200.dp -> 6
|
||||||
|
screenWidth >= 1000.dp -> 5
|
||||||
|
screenWidth >= 840.dp -> 4
|
||||||
|
else -> 3
|
||||||
|
}
|
||||||
120
patch.diff
Normal file
120
patch.diff
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
--- composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
|
||||||
|
+++ composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
|
||||||
|
@@ -10,6 +10,7 @@
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
+import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
@@ -19,6 +20,8 @@
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
+import androidx.compose.material3.FilterChip
|
||||||
|
+import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
@@ -83,6 +86,16 @@
|
||||||
|
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
+ val isTraktLibrary = manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL &&
|
||||||
|
+ subtitle == stringResource(Res.string.compose_catalog_subtitle_trakt_library)
|
||||||
|
+ var selectedFilter by remember { mutableIntStateOf(0) }
|
||||||
|
+
|
||||||
|
+ val filteredItems = remember(uiState.items, selectedFilter) {
|
||||||
|
+ if (selectedFilter == 1) uiState.items.filter { it.type.lowercase() in listOf("movie", "film") }
|
||||||
|
+ else if (selectedFilter == 2) uiState.items.filter { it.type.lowercase() in listOf("series", "tv", "show") }
|
||||||
|
+ else uiState.items
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) {
|
||||||
|
CatalogRepository.load(
|
||||||
|
manifestUrl = manifestUrl,
|
||||||
|
@@ -149,11 +162,11 @@
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
|
) {
|
||||||
|
- if (uiState.items.isEmpty() && uiState.isLoading) {
|
||||||
|
+ if (filteredItems.isEmpty() && uiState.isLoading) {
|
||||||
|
items(columns * 3) {
|
||||||
|
CatalogSkeletonTile(cornerRadiusDp = posterCardStyle.cornerRadiusDp)
|
||||||
|
}
|
||||||
|
- } else if (uiState.items.isEmpty()) {
|
||||||
|
+ } else if (filteredItems.isEmpty()) {
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
CatalogEmptyState(
|
||||||
|
errorMessage = uiState.errorMessage,
|
||||||
|
@@ -172,7 +185,7 @@
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(
|
||||||
|
- items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||||
|
+ items = filteredItems.withDuplicateSafeLazyKeys { item -> item.stableKey() },
|
||||||
|
key = { item -> item.lazyKey },
|
||||||
|
) { keyedItem ->
|
||||||
|
val item = keyedItem.value
|
||||||
|
@@ -194,6 +207,9 @@
|
||||||
|
CatalogHeader(
|
||||||
|
title = title,
|
||||||
|
subtitle = subtitle,
|
||||||
|
+ showFilters = isTraktLibrary,
|
||||||
|
+ selectedFilter = selectedFilter,
|
||||||
|
+ onFilterSelected = { selectedFilter = it },
|
||||||
|
modifier = Modifier.onSizeChanged { headerHeightPx = it.height },
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
@@ -206,6 +222,9 @@
|
||||||
|
private fun CatalogHeader(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
+ showFilters: Boolean = false,
|
||||||
|
+ selectedFilter: Int = 0,
|
||||||
|
+ onFilterSelected: (Int) -> Unit = {},
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
@@ -237,6 +256,41 @@
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
+ if (showFilters) {
|
||||||
|
+ Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
+ FilterChip(
|
||||||
|
+ selected = selectedFilter == 0,
|
||||||
|
+ onClick = { onFilterSelected(0) },
|
||||||
|
+ label = { Text(stringResource(Res.string.collections_tab_all)) },
|
||||||
|
+ colors = FilterChipDefaults.filterChipColors(
|
||||||
|
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
+ ),
|
||||||
|
+ shape = RoundedCornerShape(16.dp),
|
||||||
|
+ )
|
||||||
|
+ FilterChip(
|
||||||
|
+ selected = selectedFilter == 1,
|
||||||
|
+ onClick = { onFilterSelected(1) },
|
||||||
|
+ label = { Text(stringResource(Res.string.media_movies)) },
|
||||||
|
+ colors = FilterChipDefaults.filterChipColors(
|
||||||
|
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
+ ),
|
||||||
|
+ shape = RoundedCornerShape(16.dp),
|
||||||
|
+ )
|
||||||
|
+ FilterChip(
|
||||||
|
+ selected = selectedFilter == 2,
|
||||||
|
+ onClick = { onFilterSelected(2) },
|
||||||
|
+ label = { Text(stringResource(Res.string.media_series)) },
|
||||||
|
+ colors = FilterChipDefaults.filterChipColors(
|
||||||
|
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
+ ),
|
||||||
|
+ shape = RoundedCornerShape(16.dp),
|
||||||
|
+ )
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
patch2.diff
Normal file
12
patch2.diff
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
--- composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
|
||||||
|
+++ composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
|
||||||
|
@@ -86,8 +86,7 @@
|
||||||
|
var headerHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
var observedOfflineState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
- val isTraktLibrary = manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL &&
|
||||||
|
- subtitle == stringResource(Res.string.compose_catalog_subtitle_trakt_library)
|
||||||
|
+ val isTraktLibrary = manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL && subtitle.contains("Trakt", ignoreCase = true)
|
||||||
|
var selectedFilter by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val filteredItems = remember(uiState.items, selectedFilter) {
|
||||||
10
patch3.diff
Normal file
10
patch3.diff
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
--- composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
|
||||||
|
+++ composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt
|
||||||
|
@@ -37,6 +37,7 @@
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||||
|
import com.nuvio.app.core.ui.NuvioBackButton
|
||||||
|
+import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
||||||
|
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||||
|
import com.nuvio.app.core.ui.posterCardClickable
|
||||||
|
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||||
Loading…
Reference in a new issue