diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index f58cd2df..3539c866 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -11,6 +11,7 @@ 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 @@ -21,6 +22,8 @@ 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 @@ -47,6 +50,7 @@ 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.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 @@ -82,7 +86,16 @@ fun CatalogScreen( var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } - LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) { + 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, @@ -100,7 +113,7 @@ fun CatalogScreen( lastVisible >= layoutInfo.totalItemsCount - 6 } .distinctUntilChanged() - .filter { it && uiState.canLoadMore && !uiState.isLoading } + .filter { it && uiState.canLoadMore && !uiState.isLoading && selectedFilter == 0 } // Prevent aggressive fetching when filtered .collect { CatalogRepository.loadMore() } @@ -154,11 +167,11 @@ fun CatalogScreen( 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, @@ -178,7 +191,7 @@ fun CatalogScreen( } } else { items( - items = uiState.items.withDuplicateSafeLazyKeys { item -> item.stableKey() }, + items = filteredItems.withDuplicateSafeLazyKeys { item -> item.stableKey() }, key = { item -> item.lazyKey }, ) { keyedItem -> val item = keyedItem.value @@ -200,6 +213,9 @@ fun CatalogScreen( CatalogHeader( title = title, subtitle = subtitle, + showFilters = isTraktLibrary, + selectedFilter = selectedFilter, + onFilterSelected = { selectedFilter = it }, modifier = Modifier.onSizeChanged { headerHeightPx = it.height }, onBack = onBack, ) @@ -211,6 +227,9 @@ fun CatalogScreen( private fun CatalogHeader( title: String, subtitle: String, + showFilters: Boolean = false, + selectedFilter: Int = 0, + onFilterSelected: (Int) -> Unit = {}, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -248,6 +267,41 @@ private fun CatalogHeader( 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), + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt.orig b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt.orig new file mode 100644 index 00000000..d5d7c950 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt.orig @@ -0,0 +1,432 @@ +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.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 +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 + } diff --git a/patch.diff b/patch.diff new file mode 100644 index 00000000..a47004f7 --- /dev/null +++ b/patch.diff @@ -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), ++ ) ++ } ++ } + } + } diff --git a/patch2.diff b/patch2.diff new file mode 100644 index 00000000..4d8c8508 --- /dev/null +++ b/patch2.diff @@ -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) { diff --git a/patch3.diff b/patch3.diff new file mode 100644 index 00000000..399f1dcb --- /dev/null +++ b/patch3.diff @@ -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 diff --git a/patch4.diff b/patch4.diff new file mode 100644 index 00000000..7b7ac881 --- /dev/null +++ b/patch4.diff @@ -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 +@@ -107,7 +107,7 @@ + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisible >= layoutInfo.totalItemsCount - 6 + } + .distinctUntilChanged() +- .filter { it && uiState.canLoadMore && !uiState.isLoading } ++ .filter { it && uiState.canLoadMore && !uiState.isLoading && selectedFilter == 0 } // Prevent aggressive fetching when filtered + .collect { + CatalogRepository.loadMore() + }