From f2d235d2570822490e9ad66a0974d24681fb6e7e Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Tue, 10 Feb 2026 21:54:36 +0200 Subject: [PATCH 1/5] feat: parse belongs to collection response --- .../fixtures/model/details/MediaDetailsFactory.kt | 1 + .../com/divinelink/core/model/details/Collection.kt | 11 +++++++++++ .../divinelink/core/model/details/MediaDetails.kt | 1 + .../com/divinelink/core/model/details/Movie.kt | 1 + .../details/BelongsToCollectionResponseMapper.kt | 11 +++++++++++ .../media/model/details/DetailsResponseApi.kt | 5 +++-- .../media/model/movie/BelongsToCollectionResponse.kt | 12 ++++++++++++ 7 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Collection.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/BelongsToCollectionResponseMapper.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/BelongsToCollectionResponse.kt diff --git a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/model/details/MediaDetailsFactory.kt b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/model/details/MediaDetailsFactory.kt index fb3dfdd95..e747233b9 100644 --- a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/model/details/MediaDetailsFactory.kt +++ b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/model/details/MediaDetailsFactory.kt @@ -46,6 +46,7 @@ object MediaDetailsFactory { imdbId = "tt0137523", tagline = "You don't talk about Fight Club.", popularity = 21.6213, + collection = null, information = MediaDetailsInformation.Movie( originalTitle = "Fight Club", status = "Released", diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Collection.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Collection.kt new file mode 100644 index 000000000..9c388bc73 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Collection.kt @@ -0,0 +1,11 @@ +package com.divinelink.core.model.details + +import kotlinx.serialization.Serializable + +@Serializable +data class Collection( + val id: Int, + val name: String, + val posterPath: String?, + val backdropPath: String?, +) diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/MediaDetails.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/MediaDetails.kt index bc3eb5004..51b4ab6ed 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/MediaDetails.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/MediaDetails.kt @@ -54,6 +54,7 @@ sealed class MediaDetails { runtime = runtime, imdbId = imdbId, popularity = popularity, + collection = collection, information = information, ) is TV -> TV( diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Movie.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Movie.kt index 09f027d44..5b586f9e1 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Movie.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Movie.kt @@ -20,6 +20,7 @@ data class Movie( val runtime: String?, val cast: List, val creators: List?, + val collection: Collection?, override val imdbId: String?, override val information: MediaDetailsInformation.Movie, override val popularity: Double, diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/BelongsToCollectionResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/BelongsToCollectionResponseMapper.kt new file mode 100644 index 000000000..db82f6783 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/BelongsToCollectionResponseMapper.kt @@ -0,0 +1,11 @@ +package com.divinelink.core.network.media.mapper.details + +import com.divinelink.core.model.details.Collection +import com.divinelink.core.network.media.model.movie.BelongsToCollectionResponse + +fun BelongsToCollectionResponse.map() = Collection( + id = id, + name = name, + posterPath = posterPath, + backdropPath = backdropPath, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/DetailsResponseApi.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/DetailsResponseApi.kt index 2088ed88a..7895a4d0e 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/DetailsResponseApi.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/DetailsResponseApi.kt @@ -22,9 +22,9 @@ import com.divinelink.core.network.media.model.details.credits.CrewApi import com.divinelink.core.network.media.model.details.credits.SeriesCreatorApi import com.divinelink.core.network.media.model.details.season.SeasonResponseApi import com.divinelink.core.network.media.model.details.tv.NextEpisodeToAirResponse +import com.divinelink.core.network.media.model.movie.BelongsToCollectionResponse import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject @Serializable(with = DetailsResponseApiSerializer::class) sealed class DetailsResponseApi { @@ -59,7 +59,6 @@ sealed class DetailsResponseApi { override val id: Int, override val adult: Boolean, @SerialName("backdrop_path") override val backdropPath: String?, - @SerialName("belongs_to_collection") val belongToCollection: JsonObject? = null, val budget: Int, override val genres: List, val homepage: String? = null, @@ -82,6 +81,7 @@ sealed class DetailsResponseApi { @SerialName("vote_count") override val voteCount: Int, val credits: CreditsApi? = null, // TODO credits call should be made separately val status: String? = null, + @SerialName("belongs_to_collection") val collection: BelongsToCollectionResponse?, ) : DetailsResponseApi() @Serializable @@ -138,6 +138,7 @@ private fun DetailsResponseApi.Movie.toDomainMovie(): MediaDetails = Movie( isFavorite = false, imdbId = this.imdbId, popularity = popularity, + collection = collection?.map(), information = MediaDetailsInformation.Movie( originalTitle = originalTitle, status = status ?: "-", diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/BelongsToCollectionResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/BelongsToCollectionResponse.kt new file mode 100644 index 000000000..bb3acf32a --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/BelongsToCollectionResponse.kt @@ -0,0 +1,12 @@ +package com.divinelink.core.network.media.model.movie + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BelongsToCollectionResponse( + val id: Int, + val name: String, + @SerialName("poster_path") val posterPath: String?, + @SerialName("backdrop_path") val backdropPath: String?, +) From 8f0b5b8b595db27772a2323875716b4ded01384a Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Tue, 10 Feb 2026 22:34:44 +0200 Subject: [PATCH 2/5] feat: add part of collection banner on details --- .../details/media/DetailsDataFactory.kt | 3 ++ .../core/model/details/media/DetailsData.kt | 2 + .../details/CollectionBackdropImage.kt | 39 ++++++++++++++ .../composeResources/values/strings.xml | 3 ++ .../details/media/ui/DetailsViewModel.kt | 1 + .../media/ui/forms/about/AboutFormContent.kt | 11 ++++ .../media/ui/forms/about/CollectionBanner.kt | 54 +++++++++++++++++++ 7 files changed, 113 insertions(+) create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt create mode 100644 feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt diff --git a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt index 1a1758160..f553490b9 100644 --- a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt +++ b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt @@ -16,6 +16,7 @@ object DetailsDataFactory { genres = null, creators = null, information = null, + collection = null, ) fun cast(isTv: Boolean) = DetailsData.Cast( @@ -43,6 +44,7 @@ object DetailsDataFactory { genres = MediaDetailsFactory.FightClub().genres, creators = MediaDetailsFactory.FightClub().creators, information = MediaDetailsFactory.FightClub().information, + collection = null, //MediaDetailsFactory.FightClub().information ) fun cast() = DetailsData.Cast( @@ -66,6 +68,7 @@ object DetailsDataFactory { genres = MediaDetailsFactory.TheOffice().genres, creators = MediaDetailsFactory.TheOffice().creators, information = MediaDetailsFactory.TheOffice().information, + collection = null, ) fun cast() = DetailsData.Cast( diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt index e53395ef5..1060ae350 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt @@ -1,6 +1,7 @@ package com.divinelink.core.model.details.media import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Collection import com.divinelink.core.model.details.Person import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.review.Review @@ -13,6 +14,7 @@ sealed interface DetailsData { val genres: List?, val creators: List?, val information: MediaDetailsInformation?, + val collection: Collection? ) : DetailsData data class Cast( diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt new file mode 100644 index 000000000..3f0ccf584 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt @@ -0,0 +1,39 @@ +package com.divinelink.core.ui.components.details + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.coil.platformContext +import com.divinelink.core.ui.rememberConstants +import com.divinelink.core.ui.resources.core_ui_backdrop_image_placeholder +import org.jetbrains.compose.resources.stringResource + +@Composable +fun CollectionBackdropImage( + path: String?, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.FillWidth, +) { + val constants = rememberConstants() + + AsyncImage( + modifier = modifier + .alpha(0.5f) + .fillMaxWidth(), + model = ImageRequest.Builder(platformContext()) + .memoryCachePolicy(CachePolicy.ENABLED) + .diskCachePolicy(CachePolicy.ENABLED) + .data(constants.backdropUrl + path) + .crossfade(true) + .build(), + contentDescription = stringResource(UiString.core_ui_backdrop_image_placeholder), + contentScale = contentScale, + ) +} diff --git a/feature/details/src/commonMain/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index 1b37c93e8..bce7a3327 100644 --- a/feature/details/src/commonMain/composeResources/values/strings.xml +++ b/feature/details/src/commonMain/composeResources/values/strings.xml @@ -81,6 +81,9 @@ Next episode air date Seasons + Part of %1$s + View Collection + Country Countries diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt index 41044070f..a65d312ce 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsViewModel.kt @@ -800,6 +800,7 @@ class DetailsViewModel( is Movie -> (result.mediaDetails as Movie).creators }, information = result.mediaDetails.information, + collection = (result.mediaDetails as? Movie)?.collection, ) /** diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt index bec4d7a1d..1c127c1fe 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt @@ -91,6 +91,17 @@ fun AboutFormContent( } } + aboutData.collection?.let { collection -> + item { + CollectionBanner( + collection = collection, + onClick = { + + }, + ) + } + } + item { Spacer(modifier = Modifier.height(LocalBottomNavigationPadding.current)) } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt new file mode 100644 index 000000000..bfa592a37 --- /dev/null +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt @@ -0,0 +1,54 @@ +package com.divinelink.feature.details.media.ui.forms.about + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.details.Collection +import com.divinelink.core.ui.components.details.CollectionBackdropImage +import com.divinelink.feature.details.resources.Res +import com.divinelink.feature.details.resources.feature_details_part_of_collection +import com.divinelink.feature.details.resources.feature_details_view_collection +import org.jetbrains.compose.resources.stringResource + +@Composable +fun CollectionBanner( + collection: Collection, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .fillMaxWidth(), + ) { + CollectionBackdropImage( + path = collection.backdropPath, + ) + + Text( + modifier = Modifier + .padding(MaterialTheme.dimensions.keyline_16) + .align(Alignment.TopCenter), + text = stringResource(Res.string.feature_details_part_of_collection, collection.name), + style = MaterialTheme.typography.titleLarge, + ) + + TextButton( + modifier = Modifier + .padding(bottom = MaterialTheme.dimensions.keyline_4) + .align(Alignment.BottomCenter), + onClick = onClick, + ) { + Text(stringResource(Res.string.feature_details_view_collection)) + } + } +} From 9a5a9188b9902e1daa5496707f177a4e24767eb4 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Tue, 10 Feb 2026 23:49:04 +0200 Subject: [PATCH 3/5] feat: scaffolding collections screen --- app/build.gradle.kts | 1 + .../scenepeek/di/NavigationModule.kt | 9 ++ .../scenepeek/di/ViewModelModule.kt | 2 + .../home/navigation/NavigationRouter.kt | 2 + .../core/navigation/route/CollectionsRoute.kt | 7 ++ .../core/navigation/route/Navigation.kt | 8 ++ feature/collections/build.gradle.kts | 22 ++++ .../composeResources/values/strings.xml | 3 + .../feature/collections/CollectionsAction.kt | 4 + .../feature/collections/CollectionsUiState.kt | 19 +++ .../collections/CollectionsViewModel.kt | 29 +++++ .../collections/ui/CollectionsContent.kt | 71 ++++++++++++ .../collections/ui/CollectionsScreen.kt | 109 ++++++++++++++++++ .../ui/navigation/CollectionsNavigation.kt | 14 +++ .../CollectionsUiStateParameterProvider.kt | 9 ++ .../details/media/ui/DetailsContent.kt | 3 +- .../media/ui/forms/about/AboutFormContent.kt | 23 ++-- .../media/ui/forms/about/CollectionBanner.kt | 9 +- settings.gradle.kts | 1 + 19 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/CollectionsRoute.kt create mode 100644 feature/collections/build.gradle.kts create mode 100644 feature/collections/src/commonMain/composeResources/values/strings.xml create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/navigation/CollectionsNavigation.kt create mode 100644 feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57ce65d42..068c8dc90 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,7 @@ kotlin { api(projects.core.scaffold) implementation(projects.feature.addToAccount) + implementation(projects.feature.collections) implementation(projects.feature.credits) implementation(projects.feature.details) implementation(projects.feature.discover) diff --git a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt index 1fe7df19d..ddb5b210c 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt @@ -7,6 +7,7 @@ import com.divinelink.core.navigation.route.Navigation.JellyseerrSettingsRoute import com.divinelink.core.scaffold.NavGraphExtension import com.divinelink.feature.add.to.account.list.navigation.addToListScreen import com.divinelink.feature.add.to.account.modal.navigation.defaultMediaActionMenu +import com.divinelink.feature.collections.ui.navigation.collectionsScreen import com.divinelink.feature.credits.navigation.creditsScreen import com.divinelink.feature.details.navigation.detailsScreen import com.divinelink.feature.details.navigation.personScreen @@ -306,6 +307,14 @@ val navigationModule = module { } } + single(named()) { + { navController, _ -> + collectionsScreen( + onNavigate = navController::findNavigation, + ) + } + } + single> { getAll() } diff --git a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt index c6445e436..7be297784 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt @@ -3,6 +3,7 @@ package com.divinelink.scenepeek.di import com.divinelink.core.domain.components.SwitchViewButtonViewModel import com.divinelink.feature.add.to.account.list.AddToListViewModel import com.divinelink.feature.add.to.account.modal.ActionMenuViewModel +import com.divinelink.feature.collections.CollectionsViewModel import com.divinelink.feature.credits.ui.CreditsViewModel import com.divinelink.feature.details.media.ui.DetailsViewModel import com.divinelink.feature.details.person.ui.PersonViewModel @@ -34,6 +35,7 @@ import org.koin.dsl.module val appViewModelModule = module { viewModelOf(::AccountSettingsViewModel) viewModelOf(::AppearanceSettingsViewModel) + viewModelOf(::CollectionsViewModel) viewModelOf(::CreditsViewModel) viewModelOf(::DetailsViewModel) viewModelOf(::HomeViewModel) diff --git a/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt b/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt index dc74c6307..309306d5f 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/home/navigation/NavigationRouter.kt @@ -4,6 +4,7 @@ import androidx.navigation.NavController import com.divinelink.core.model.search.SearchEntryPoint import com.divinelink.core.navigation.route.Navigation import com.divinelink.core.navigation.route.navigateToAddToList +import com.divinelink.core.navigation.route.navigateToCollection import com.divinelink.core.navigation.route.navigateToCreateList import com.divinelink.core.navigation.route.navigateToDetails import com.divinelink.core.navigation.route.navigateToDiscover @@ -70,6 +71,7 @@ fun NavController.findNavigation(route: Navigation) { is Navigation.MediaListsRoute -> navigateToMediaLists(route) is Navigation.SeasonRoute -> navigateToSeason(route) is Navigation.EpisodeRoute -> navigateToEpisode(route) + is Navigation.CollectionRoute -> navigateToCollection(route) // This is from top level navigation Navigation.HomeRoute -> Unit diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/CollectionsRoute.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/CollectionsRoute.kt new file mode 100644 index 000000000..08266c82e --- /dev/null +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/CollectionsRoute.kt @@ -0,0 +1,7 @@ +package com.divinelink.core.navigation.route + +import androidx.navigation.NavController + +fun NavController.navigateToCollection(route: Navigation.CollectionRoute) = navigate( + route = route, +) diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt index 931a141bb..68929740e 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt @@ -157,4 +157,12 @@ sealed interface Navigation { @Serializable data class MediaListsRoute(val section: MediaListSection) : Navigation + + @Serializable + data class CollectionRoute( + val id: Int, + val name: String, + val backdropPath: String?, + val posterPath: String?, + ) : Navigation } diff --git a/feature/collections/build.gradle.kts b/feature/collections/build.gradle.kts new file mode 100644 index 000000000..d8c125b23 --- /dev/null +++ b/feature/collections/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.divinelink.kotlin.multiplatform) + alias(libs.plugins.divinelink.compose.multiplatform) + + alias(libs.plugins.divinelink.compose.feature) +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(projects.core.data) + implementation(projects.core.domain) + implementation(projects.core.fixtures) + } + } +} + +compose.resources { + publicResClass = true + packageOfResClass = "com.divinelink.feature.collections" + generateResClass = auto +} diff --git a/feature/collections/src/commonMain/composeResources/values/strings.xml b/feature/collections/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..6b30d8d9d --- /dev/null +++ b/feature/collections/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt new file mode 100644 index 000000000..de8da8b74 --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt @@ -0,0 +1,4 @@ +package com.divinelink.feature.collections + +sealed interface CollectionsAction + diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt new file mode 100644 index 000000000..2e3cdfcee --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt @@ -0,0 +1,19 @@ +package com.divinelink.feature.collections + +import com.divinelink.core.navigation.route.Navigation + +data class CollectionsUiState( + val id: Int, + val collectionName: String, + val backdropPath: String?, + val posterPath: String?, +) { + companion object { + fun initial(route: Navigation.CollectionRoute) = CollectionsUiState( + id = route.id, + collectionName = route.name, + backdropPath = route.backdropPath, + posterPath = route.posterPath, + ) + } +} diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt new file mode 100644 index 000000000..038b3bdfa --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt @@ -0,0 +1,29 @@ +package com.divinelink.feature.collections + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.divinelink.core.navigation.route.Navigation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class CollectionsViewModel( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val route = Navigation.CollectionRoute( + id = savedStateHandle.get("id") ?: -1, + name = savedStateHandle.get("name") ?: "", + backdropPath = savedStateHandle.get("backdropPath") ?: "", + posterPath = savedStateHandle.get("posterPath") ?: "", + ) + + private val _uiState: MutableStateFlow = MutableStateFlow( + CollectionsUiState.initial(route), + ) + val uiState: StateFlow = _uiState + + init { + + } +} + diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt new file mode 100644 index 000000000..d6cb2b412 --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt @@ -0,0 +1,71 @@ +package com.divinelink.feature.collections.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.Previews +import com.divinelink.core.ui.SharedTransitionScopeProvider +import com.divinelink.core.ui.collapsingheader.ui.DetailCollapsibleContent +import com.divinelink.core.ui.composition.PreviewLocalProvider +import com.divinelink.feature.collections.CollectionsUiState +import com.divinelink.feature.collections.ui.provider.CollectionsUiStateParameterProvider + +@Composable +fun SharedTransitionScope.CollectionsContent( + visibilityScope: AnimatedVisibilityScope, + uiState: CollectionsUiState, + onBackdropLoaded: () -> Unit, + toolbarProgress: (Float) -> Unit, + onNavigate: (Navigation) -> Unit, +) { + DetailCollapsibleContent( + visibilityScope = visibilityScope, + backdropPath = uiState.backdropPath, + posterPath = uiState.posterPath, + toolbarProgress = toolbarProgress, + onBackdropLoaded = onBackdropLoaded, + onNavigateToMediaPoster = { onNavigate(Navigation.MediaPosterRoute(it)) }, + headerContent = { + Text( + text = uiState.collectionName, + style = MaterialTheme.typography.titleLarge, + ) + }, + content = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + ) { + Text( + modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), + text = "", + style = MaterialTheme.typography.bodySmall, + ) + } + }, + ) +} + +@Composable +@Previews +fun CollectionsContentPreview(@PreviewParameter(CollectionsUiStateParameterProvider::class) state: CollectionsUiState) { + PreviewLocalProvider { + SharedTransitionScopeProvider { scope -> + scope.CollectionsContent( + visibilityScope = this, + uiState = state, + onBackdropLoaded = {}, + toolbarProgress = {}, + onNavigate = {}, + ) + } + } +} diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt new file mode 100644 index 000000000..d45aac98d --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt @@ -0,0 +1,109 @@ +package com.divinelink.feature.collections.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.divinelink.core.designsystem.theme.LocalDarkThemeProvider +import com.divinelink.core.designsystem.theme.rememberSystemUiController +import com.divinelink.core.model.UIText +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.scaffold.PersistentNavigationBar +import com.divinelink.core.scaffold.PersistentNavigationRail +import com.divinelink.core.scaffold.PersistentScaffold +import com.divinelink.core.scaffold.rememberScaffoldState +import com.divinelink.core.ui.components.AppTopAppBar +import com.divinelink.feature.collections.CollectionsViewModel +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnimatedVisibilityScope.CollectionsScreen( + onNavigate: (Navigation) -> Unit, + viewModel: CollectionsViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + + val systemUiController = rememberSystemUiController() + var onBackdropLoaded by remember { mutableStateOf(false) } + val isDarkTheme = LocalDarkThemeProvider.current + var toolbarProgress by remember { mutableFloatStateOf(0F) } + val textColor = when { + toolbarProgress > 0.5 -> MaterialTheme.colorScheme.onSurface + + onBackdropLoaded -> if (LocalDarkThemeProvider.current) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.surface + } + + else -> if (LocalDarkThemeProvider.current) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface + } + } + + val surfaceColor = MaterialTheme.colorScheme.surface + DisposableEffect(textColor) { + val isLight = textColor == surfaceColor + systemUiController.setStatusBarColor(isLight = !isLight && !isDarkTheme) + onDispose { + systemUiController.setStatusBarColor(isLight = !isDarkTheme) + } + } + + rememberScaffoldState( + animatedVisibilityScope = this, + ).PersistentScaffold( + navigationRail = { + PersistentNavigationRail() + }, + navigationBar = { + PersistentNavigationBar() + }, + topBar = { + AppTopAppBar( + scrollBehavior = scrollBehavior, + text = UIText.StringText(uiState.collectionName), + contentColor = textColor, + progress = toolbarProgress, + onNavigateUp = { onNavigate(Navigation.Back) }, + topAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + }, + content = { + Column { + if (uiState.backdropPath?.isBlank() == true) { + Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + } + + CollectionsContent( + visibilityScope = this@CollectionsScreen, + uiState = uiState, + onBackdropLoaded = { onBackdropLoaded = true }, + toolbarProgress = { progress -> toolbarProgress = progress }, + onNavigate = onNavigate, + ) + } + }, + ) +} diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/navigation/CollectionsNavigation.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/navigation/CollectionsNavigation.kt new file mode 100644 index 000000000..b4ed4898b --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/navigation/CollectionsNavigation.kt @@ -0,0 +1,14 @@ +package com.divinelink.feature.collections.ui.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.feature.collections.ui.CollectionsScreen + +fun NavGraphBuilder.collectionsScreen(onNavigate: (Navigation) -> Unit) { + composable { + CollectionsScreen( + onNavigate = onNavigate, + ) + } +} diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt new file mode 100644 index 000000000..3d81143aa --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt @@ -0,0 +1,9 @@ +package com.divinelink.feature.collections.ui.provider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.divinelink.feature.collections.CollectionsUiState + +class CollectionsUiStateParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf() +} + diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt index 8d0d4c3e0..6c62ee2a7 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/DetailsContent.kt @@ -461,8 +461,7 @@ private fun SharedTransitionScope.MediaDetailsContent( is DetailsData.About -> AboutFormContent( modifier = Modifier.fillMaxSize(), aboutData = form.data as DetailsData.About, - onPersonClick = onPersonClick, - onGenreClick = {}, + onNavigate = onNavigate, ) is DetailsData.Cast -> CastFormContent( modifier = Modifier.fillMaxSize(), diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt index 1c127c1fe..57c34ca57 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/AboutFormContent.kt @@ -14,10 +14,10 @@ import androidx.compose.ui.text.font.FontStyle import com.divinelink.core.designsystem.component.ScenePeekLazyColumn import com.divinelink.core.designsystem.theme.LocalBottomNavigationPadding import com.divinelink.core.designsystem.theme.dimensions -import com.divinelink.core.model.Genre -import com.divinelink.core.model.details.Person import com.divinelink.core.model.details.media.DetailsData import com.divinelink.core.model.details.media.MediaDetailsInformation +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.navigation.route.toPersonRoute import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.components.details.cast.CreatorsItem import com.divinelink.feature.details.media.ui.components.GenresSection @@ -28,8 +28,7 @@ import com.divinelink.feature.details.media.ui.components.TvInformationSection fun AboutFormContent( modifier: Modifier = Modifier, aboutData: DetailsData.About, - onGenreClick: (Genre) -> Unit, - onPersonClick: (Person) -> Unit, + onNavigate: (Navigation) -> Unit, ) { ScenePeekLazyColumn( modifier = modifier.testTag(TestTags.Details.About.FORM), @@ -65,7 +64,10 @@ fun AboutFormContent( aboutData.genres?.let { genres -> item { - GenresSection(genres, onGenreClick) + GenresSection( + genres = genres, + onGenreClick = {}, + ) } } @@ -73,7 +75,7 @@ fun AboutFormContent( item { CreatorsItem( creators = creators, - onClick = onPersonClick, + onClick = { onNavigate(it.toPersonRoute()) }, ) } } @@ -96,7 +98,14 @@ fun AboutFormContent( CollectionBanner( collection = collection, onClick = { - + onNavigate( + Navigation.CollectionRoute( + id = collection.id, + name = collection.name, + backdropPath = collection.backdropPath, + posterPath = collection.posterPath, + ), + ) }, ) } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt index bfa592a37..d7ca38e1e 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -47,8 +48,14 @@ fun CollectionBanner( .padding(bottom = MaterialTheme.dimensions.keyline_4) .align(Alignment.BottomCenter), onClick = onClick, + colors = ButtonDefaults.textButtonColors().copy( + contentColor = MaterialTheme.colorScheme.onSurface, + ), ) { - Text(stringResource(Res.string.feature_details_view_collection)) + Text( + text = stringResource(Res.string.feature_details_view_collection), + style = MaterialTheme.typography.titleSmall, + ) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2f8a7db94..68e7e1c62 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,7 @@ include(":core:testing") include(":core:fixtures") include(":feature:add-to-account") +include(":feature:collections") include(":feature:credits") include(":feature:details") include(":feature:season") From fb363a5533c767ffe71c1220860d7aaf426b2515 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Wed, 11 Feb 2026 00:33:23 +0200 Subject: [PATCH 4/5] feat: implement collection details api call and ui --- .../details/repository/DetailsRepository.kt | 3 + .../repository/ProdDetailsRepository.kt | 6 ++ .../details/media/DetailsDataFactory.kt | 2 +- .../core/model/details/CollectionDetails.kt | 11 +++ .../core/model/details/media/DetailsData.kt | 2 +- .../CollectionDetailsResponseMapper.kt | 17 ++++ .../details/CollectionDetailsResponse.kt | 16 ++++ .../media/model/movie/MoviesResponseApi.kt | 2 +- .../network/media/service/MediaService.kt | 3 + .../network/media/service/ProdMediaService.kt | 7 ++ .../core/network/media/util/BuildUrl.kt | 10 +++ .../core/scaffold/ScenePeekNavHost.kt | 1 + .../ui/CollapsibleHeaderContent.kt | 1 + .../details/reviews/ReviewItemCard.kt | 1 + .../ui/components/expanding/ExpandingText.kt | 12 +-- .../core/ui/text/SimpleExpandingText.kt | 12 ++- .../feature/collections/CollectionsAction.kt | 5 +- .../feature/collections/CollectionsUiState.kt | 10 +++ .../collections/CollectionsViewModel.kt | 47 ++++++++++- .../collections/ui/CollectionsContent.kt | 77 ++++++++++++++++--- .../collections/ui/CollectionsScreen.kt | 4 + .../CollectionsUiStateParameterProvider.kt | 1 - .../composeResources/values/strings.xml | 2 +- 23 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/details/CollectionDetails.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/CollectionDetailsResponseMapper.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/CollectionDetailsResponse.kt diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/DetailsRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/DetailsRepository.kt index cf8518f83..7851b3a04 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/DetailsRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/DetailsRepository.kt @@ -3,6 +3,7 @@ package com.divinelink.core.data.details.repository import com.divinelink.core.model.PaginationData import com.divinelink.core.model.account.AccountMediaDetails import com.divinelink.core.model.credits.AggregateCredits +import com.divinelink.core.model.details.CollectionDetails import com.divinelink.core.model.details.MediaDetails import com.divinelink.core.model.details.rating.ExternalRatings import com.divinelink.core.model.details.rating.RatingDetails @@ -60,4 +61,6 @@ interface DetailsRepository { ): Flow> fun findById(id: String): Flow> + + suspend fun fetchCollectionDetails(id: Int): Result } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt index 6feb7e526..e30a4c6ae 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt @@ -16,6 +16,7 @@ import com.divinelink.core.database.media.dao.MediaDao import com.divinelink.core.model.PaginationData import com.divinelink.core.model.account.AccountMediaDetails import com.divinelink.core.model.credits.AggregateCredits +import com.divinelink.core.model.details.CollectionDetails import com.divinelink.core.model.details.MediaDetails import com.divinelink.core.model.details.TV import com.divinelink.core.model.details.rating.ExternalRatings @@ -27,6 +28,7 @@ import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaReference import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.Resource +import com.divinelink.core.network.media.mapper.details.map import com.divinelink.core.network.media.mapper.find.map import com.divinelink.core.network.media.model.MediaRequestApi import com.divinelink.core.network.media.model.credits.AggregateCreditsApi @@ -254,4 +256,8 @@ class ProdDetailsRepository( .map { Result.success(it.map()) } + + override suspend fun fetchCollectionDetails(id: Int): Result = mediaRemote + .fetchCollectionDetails(id) + .map { it.map() } } diff --git a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt index f553490b9..e7fcf0700 100644 --- a/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt +++ b/core/fixtures/src/commonMain/kotlin/com/divinelink/core/fixtures/details/media/DetailsDataFactory.kt @@ -44,7 +44,7 @@ object DetailsDataFactory { genres = MediaDetailsFactory.FightClub().genres, creators = MediaDetailsFactory.FightClub().creators, information = MediaDetailsFactory.FightClub().information, - collection = null, //MediaDetailsFactory.FightClub().information + collection = null, // MediaDetailsFactory.FightClub().information ) fun cast() = DetailsData.Cast( diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/CollectionDetails.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/CollectionDetails.kt new file mode 100644 index 000000000..734400cec --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/CollectionDetails.kt @@ -0,0 +1,11 @@ +package com.divinelink.core.model.details + +import com.divinelink.core.model.media.MediaItem +import kotlinx.serialization.Serializable + +@Serializable +data class CollectionDetails( + val collection: Collection, + val overview: String, + val movies: List, +) diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt index 1060ae350..1255c3d8b 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/media/DetailsData.kt @@ -14,7 +14,7 @@ sealed interface DetailsData { val genres: List?, val creators: List?, val information: MediaDetailsInformation?, - val collection: Collection? + val collection: Collection?, ) : DetailsData data class Cast( diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/CollectionDetailsResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/CollectionDetailsResponseMapper.kt new file mode 100644 index 000000000..f10c71d56 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/CollectionDetailsResponseMapper.kt @@ -0,0 +1,17 @@ +package com.divinelink.core.network.media.mapper.details + +import com.divinelink.core.model.details.Collection +import com.divinelink.core.model.details.CollectionDetails +import com.divinelink.core.network.media.model.details.CollectionDetailsResponse +import com.divinelink.core.network.media.model.movie.toMovie + +fun CollectionDetailsResponse.map() = CollectionDetails( + collection = Collection( + id = id, + name = name, + posterPath = posterPath, + backdropPath = backdropPath, + ), + overview = overview, + movies = parts.map { it.toMovie() }, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/CollectionDetailsResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/CollectionDetailsResponse.kt new file mode 100644 index 000000000..bd4519749 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/CollectionDetailsResponse.kt @@ -0,0 +1,16 @@ +package com.divinelink.core.network.media.model.details + +import com.divinelink.core.network.media.model.movie.MovieResponseApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CollectionDetailsResponse( + val id: Int, + val name: String, + @SerialName("original_name") val originalName: String, + val overview: String, + @SerialName("poster_path") val posterPath: String?, + @SerialName("backdrop_path") val backdropPath: String?, + val parts: List, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/MoviesResponseApi.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/MoviesResponseApi.kt index 522ba3b4a..fbe4c3948 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/MoviesResponseApi.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/movie/MoviesResponseApi.kt @@ -40,7 +40,7 @@ fun MoviesResponseApi.map(): PaginationData = PaginationData( list = this.results.map(MovieResponseApi::toMovie), ) -private fun MovieResponseApi.toMovie() = MediaItem.Media.Movie( +fun MovieResponseApi.toMovie() = MediaItem.Media.Movie( id = this.id, posterPath = this.posterPath ?: "", releaseDate = this.releaseDate, diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt index f268ccd3b..57dffaddb 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt @@ -7,6 +7,7 @@ import com.divinelink.core.model.sort.SortOption import com.divinelink.core.network.media.model.GenresListResponse import com.divinelink.core.network.media.model.MediaRequestApi import com.divinelink.core.network.media.model.credits.AggregateCreditsApi +import com.divinelink.core.network.media.model.details.CollectionDetailsResponse import com.divinelink.core.network.media.model.details.DetailsResponseApi import com.divinelink.core.network.media.model.details.reviews.ReviewsResponseApi import com.divinelink.core.network.media.model.details.season.SeasonDetailsResponse @@ -83,4 +84,6 @@ interface MediaService { showId: Int, season: Int, ): Result + + suspend fun fetchCollectionDetails(id: Int): Result } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt index 1c4badf84..fc3eb4a56 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/ProdMediaService.kt @@ -10,6 +10,7 @@ import com.divinelink.core.network.client.TMDbClient import com.divinelink.core.network.media.model.GenresListResponse import com.divinelink.core.network.media.model.MediaRequestApi import com.divinelink.core.network.media.model.credits.AggregateCreditsApi +import com.divinelink.core.network.media.model.details.CollectionDetailsResponse import com.divinelink.core.network.media.model.details.DetailsResponseApi import com.divinelink.core.network.media.model.details.reviews.ReviewsResponseApi import com.divinelink.core.network.media.model.details.season.SeasonDetailsResponse @@ -28,6 +29,7 @@ import com.divinelink.core.network.media.model.search.multi.MultiSearchResponseA import com.divinelink.core.network.media.model.states.AccountMediaDetailsRequestApi import com.divinelink.core.network.media.model.states.AccountMediaDetailsResponseApi import com.divinelink.core.network.media.model.tv.TvResponseApi +import com.divinelink.core.network.media.util.buildCollectionsUrl import com.divinelink.core.network.media.util.buildDiscoverUrl import com.divinelink.core.network.media.util.buildFetchDetailsUrl import com.divinelink.core.network.media.util.buildFetchMediaListUrl @@ -271,4 +273,9 @@ class ProdMediaService( ), ) } + + override suspend fun fetchCollectionDetails(id: Int): Result = + runCatching { + restClient.get(url = buildCollectionsUrl(id = id)) + } } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt index 6fdc6561d..1ce0a1e97 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/util/BuildUrl.kt @@ -148,3 +148,13 @@ fun buildSeasonDetailsUrl( } } }.toString() + +fun buildCollectionsUrl(id: Int): String = buildUrl { + protocol = URLProtocol.HTTPS + host = Routes.TMDb.HOST + encodedPath = Routes.TMDb.V3 + "/collection/$id" + + parameters.apply { + append("language", "en") + } +}.toString() diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt index 8ad7d05bf..26bc94b2e 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost +import com.divinelink.core.model.home.MediaListSection import com.divinelink.core.navigation.route.Navigation typealias NavGraphExtension = NavGraphBuilder.( diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt index cac5d4846..539e2a997 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt @@ -79,6 +79,7 @@ fun SharedTransitionScope.CollapsibleHeaderContent( if (posterPath != null) { PosterImage( modifier = Modifier + .align(Alignment.Top) .sharedElement( sharedContentState = rememberSharedContentState( SharedElementKeys.MediaPoster(posterPath), diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/reviews/ReviewItemCard.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/reviews/ReviewItemCard.kt index f14ef4663..9defee1f0 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/reviews/ReviewItemCard.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/reviews/ReviewItemCard.kt @@ -73,6 +73,7 @@ fun ReviewItemCard( } SimpleExpandingText( modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), + style = MaterialTheme.typography.bodyMedium, text = review.content.markdownToHtml().fromHtml(), ) diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/expanding/ExpandingText.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/expanding/ExpandingText.kt index e2a9dc1e6..4503545db 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/expanding/ExpandingText.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/expanding/ExpandingText.kt @@ -16,10 +16,12 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum import com.divinelink.core.designsystem.theme.AppTheme import com.divinelink.core.ui.Previews import com.divinelink.core.ui.UiString @@ -36,8 +38,8 @@ fun ExpandingText( expandComponent: @Composable BoxScope.(Modifier) -> Unit = {}, shrinkComponent: @Composable ColumnScope.(Modifier, () -> Unit) -> Unit = { _, _ -> }, ) { - val showMore = remember { mutableStateOf(false) } - val hasOverflow = remember { mutableStateOf(false) } + val showMore = rememberSaveable { mutableStateOf(false) } + val hasOverflow = rememberSaveable { mutableStateOf(false) } Column( modifier = modifier.clickable( @@ -86,8 +88,7 @@ private fun ExpandingTextPreview() { Surface { Column { ExpandingText( -// text = LoremIpsum(50).values.joinToString(), - text = "LoremIpsum(50).values.joinToString()", + text = LoremIpsum(50).values.joinToString(), style = MaterialTheme.typography.bodyMedium, expandComponent = { modifier -> ExpandingComponents.InlineEdgeFadingEffect( @@ -111,8 +112,7 @@ private fun ExpandingTextNoOverflowPreview() { Surface { Column { ExpandingText( -// text = LoremIpsum(20).values.joinToString(), - text = "LoremIpsum(20).values.joinToString()", + text = LoremIpsum(20).values.joinToString(), style = MaterialTheme.typography.bodyMedium, expandComponent = { modifier -> ExpandingComponents.InlineEdgeFadingEffect( diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/text/SimpleExpandingText.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/text/SimpleExpandingText.kt index e02a76521..e8b879b28 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/text/SimpleExpandingText.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/text/SimpleExpandingText.kt @@ -17,9 +17,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -38,10 +40,11 @@ private const val MINIMUM_MAX_LINES = 6 fun SimpleExpandingText( modifier: Modifier = Modifier, text: AnnotatedString, + style: TextStyle, ) { - var isExpanded by remember { mutableStateOf(false) } - var hasOverflow by remember { mutableStateOf(false) } - var maxLines by remember { mutableIntStateOf(MINIMUM_MAX_LINES) } + var isExpanded by rememberSaveable { mutableStateOf(false) } + var hasOverflow by rememberSaveable { mutableStateOf(false) } + var maxLines by rememberSaveable { mutableIntStateOf(MINIMUM_MAX_LINES) } Column( modifier = modifier @@ -78,7 +81,7 @@ fun SimpleExpandingText( }, overflow = TextOverflow.Ellipsis, text = text, - style = MaterialTheme.typography.bodyMedium, + style = style, maxLines = maxLines, ) if (hasOverflow) { @@ -111,6 +114,7 @@ fun SimpleExpandingTextPreview() { Surface { SimpleExpandingText( modifier = Modifier.padding(top = 120.dp), + style = MaterialTheme.typography.bodyMedium, text = buildAnnotatedString { append("Lorem ipsum dolor sit amet, consectetur adipiscing elit. ") append("Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ") diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt index de8da8b74..2809a58a4 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt @@ -1,4 +1,5 @@ package com.divinelink.feature.collections -sealed interface CollectionsAction - +sealed interface CollectionsAction { + data object Refresh : CollectionsAction +} diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt index 2e3cdfcee..9c284174b 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt @@ -1,12 +1,18 @@ package com.divinelink.feature.collections +import com.divinelink.core.model.media.MediaItem import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.blankslate.BlankSlateState data class CollectionsUiState( val id: Int, val collectionName: String, val backdropPath: String?, val posterPath: String?, + val loading: Boolean, + val error: BlankSlateState?, + val overview: String?, + val movies: List, ) { companion object { fun initial(route: Navigation.CollectionRoute) = CollectionsUiState( @@ -14,6 +20,10 @@ data class CollectionsUiState( collectionName = route.name, backdropPath = route.backdropPath, posterPath = route.posterPath, + error = null, + overview = null, + loading = true, + movies = emptyList(), ) } } diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt index 038b3bdfa..4e0860438 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt @@ -2,11 +2,17 @@ package com.divinelink.feature.collections import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.divinelink.core.data.details.repository.DetailsRepository import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.blankslate.BlankSlateState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class CollectionsViewModel( + private val repository: DetailsRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -23,7 +29,46 @@ class CollectionsViewModel( val uiState: StateFlow = _uiState init { + fetchCollectionDetails() + } + fun onAction(action: CollectionsAction) { + when (action) { + CollectionsAction.Refresh -> fetchCollectionDetails() + } } -} + private fun fetchCollectionDetails() { + _uiState.update { uiState -> + uiState.copy( + loading = true, + error = null, + ) + } + + viewModelScope.launch { + repository + .fetchCollectionDetails(id = uiState.value.id) + .fold( + onSuccess = { details -> + _uiState.update { uiState -> + uiState.copy( + loading = false, + error = null, + overview = details.overview, + movies = details.movies.sortedBy { it.releaseDate }, + ) + } + }, + onFailure = { + _uiState.update { uiState -> + uiState.copy( + loading = false, + error = BlankSlateState.Generic, + ) + } + }, + ) + } + } +} diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt index d6cb2b412..d7c55369a 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt @@ -3,19 +3,37 @@ package com.divinelink.feature.collections.ui import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewParameter import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.media.encodeToString +import com.divinelink.core.model.ui.SwitchPreferencesAction +import com.divinelink.core.model.ui.ViewableSection import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.navigation.utilities.toRoute import com.divinelink.core.ui.Previews import com.divinelink.core.ui.SharedTransitionScopeProvider +import com.divinelink.core.ui.blankslate.BlankSlate +import com.divinelink.core.ui.blankslate.BlankSlateState import com.divinelink.core.ui.collapsingheader.ui.DetailCollapsibleContent +import com.divinelink.core.ui.components.LoadingContent import com.divinelink.core.ui.composition.PreviewLocalProvider +import com.divinelink.core.ui.list.ScrollableMediaContent +import com.divinelink.core.ui.text.SimpleExpandingText +import com.divinelink.feature.collections.CollectionsAction import com.divinelink.feature.collections.CollectionsUiState import com.divinelink.feature.collections.ui.provider.CollectionsUiStateParameterProvider @@ -25,6 +43,8 @@ fun SharedTransitionScope.CollectionsContent( uiState: CollectionsUiState, onBackdropLoaded: () -> Unit, toolbarProgress: (Float) -> Unit, + onSwitchPreferences: (SwitchPreferencesAction) -> Unit, + onAction: (CollectionsAction) -> Unit, onNavigate: (Navigation) -> Unit, ) { DetailCollapsibleContent( @@ -35,19 +55,54 @@ fun SharedTransitionScope.CollectionsContent( onBackdropLoaded = onBackdropLoaded, onNavigateToMediaPoster = { onNavigate(Navigation.MediaPosterRoute(it)) }, headerContent = { - Text( - text = uiState.collectionName, - style = MaterialTheme.typography.titleLarge, - ) - }, - content = { Column( verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), ) { Text( - modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), - text = "", + text = uiState.collectionName, + style = MaterialTheme.typography.titleLarge, + ) + + SimpleExpandingText( + modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, + text = AnnotatedString(uiState.overview ?: ""), + ) + } + }, + content = { + when { + uiState.loading -> Box(modifier = Modifier.fillMaxSize()) { + LoadingContent( + modifier = Modifier + .padding(top = MaterialTheme.dimensions.keyline_16) + .align(Alignment.TopCenter) + .verticalScroll(rememberScrollState()), + ) + } + + uiState.error != null -> Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = MaterialTheme.dimensions.keyline_16) + .verticalScroll(rememberScrollState()), + ) { + BlankSlate( + uiState = BlankSlateState.Contact, + onRetry = { onAction(CollectionsAction.Refresh) }, + ) + } + uiState.movies.isNotEmpty() -> ScrollableMediaContent( + modifier = Modifier, + items = uiState.movies, + onLoadMore = { /* Do nothing */ }, + onSwitchPreferences = onSwitchPreferences, + onClick = { it.toRoute()?.let { route -> onNavigate(route) } }, + section = ViewableSection.SEARCH, + onLongClick = { media -> + onNavigate(Navigation.ActionMenuRoute.Media(media.encodeToString())) + }, + canLoadMore = false, ) } }, @@ -56,7 +111,9 @@ fun SharedTransitionScope.CollectionsContent( @Composable @Previews -fun CollectionsContentPreview(@PreviewParameter(CollectionsUiStateParameterProvider::class) state: CollectionsUiState) { +fun CollectionsContentPreview( + @PreviewParameter(CollectionsUiStateParameterProvider::class) state: CollectionsUiState, +) { PreviewLocalProvider { SharedTransitionScopeProvider { scope -> scope.CollectionsContent( @@ -64,6 +121,8 @@ fun CollectionsContentPreview(@PreviewParameter(CollectionsUiStateParameterProvi uiState = state, onBackdropLoaded = {}, toolbarProgress = {}, + onSwitchPreferences = {}, + onAction = {}, onNavigate = {}, ) } diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt index d45aac98d..10ac345b8 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.divinelink.core.designsystem.theme.LocalDarkThemeProvider import com.divinelink.core.designsystem.theme.rememberSystemUiController +import com.divinelink.core.domain.components.SwitchViewButtonViewModel import com.divinelink.core.model.UIText import com.divinelink.core.navigation.route.Navigation import com.divinelink.core.scaffold.PersistentNavigationBar @@ -35,6 +36,7 @@ import org.koin.compose.viewmodel.koinViewModel fun AnimatedVisibilityScope.CollectionsScreen( onNavigate: (Navigation) -> Unit, viewModel: CollectionsViewModel = koinViewModel(), + switchViewButtonViewModel: SwitchViewButtonViewModel = koinViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -101,6 +103,8 @@ fun AnimatedVisibilityScope.CollectionsScreen( uiState = uiState, onBackdropLoaded = { onBackdropLoaded = true }, toolbarProgress = { progress -> toolbarProgress = progress }, + onSwitchPreferences = switchViewButtonViewModel::onAction, + onAction = viewModel::onAction, onNavigate = onNavigate, ) } diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt index 3d81143aa..fb9f4827d 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt @@ -6,4 +6,3 @@ import com.divinelink.feature.collections.CollectionsUiState class CollectionsUiStateParameterProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf() } - diff --git a/feature/details/src/commonMain/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index bce7a3327..7f1be5834 100644 --- a/feature/details/src/commonMain/composeResources/values/strings.xml +++ b/feature/details/src/commonMain/composeResources/values/strings.xml @@ -82,7 +82,7 @@ Seasons Part of %1$s - View Collection + View the collection Country From 21f06c7336b46e1cce6ace4b6c9aca9a7b65f209 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Wed, 11 Feb 2026 19:55:07 +0200 Subject: [PATCH 5/5] feat: polish collection banner and sorting --- .../api/DetailsResponseApiFactory.kt | 2 +- .../core/scaffold/ScenePeekNavHost.kt | 1 - .../details/CollectionBackdropImage.kt | 2 + .../collections/CollectionsViewModel.kt | 8 ++- .../composeResources/values/strings.xml | 2 +- .../media/ui/forms/about/CollectionBanner.kt | 55 ++++++++++++------- 6 files changed, 45 insertions(+), 25 deletions(-) diff --git a/app/src/androidHostTest/kotlin/com/divinelink/factories/api/DetailsResponseApiFactory.kt b/app/src/androidHostTest/kotlin/com/divinelink/factories/api/DetailsResponseApiFactory.kt index 78b9eef55..3b13a8629 100644 --- a/app/src/androidHostTest/kotlin/com/divinelink/factories/api/DetailsResponseApiFactory.kt +++ b/app/src/androidHostTest/kotlin/com/divinelink/factories/api/DetailsResponseApiFactory.kt @@ -13,7 +13,7 @@ object DetailsResponseApiFactory { fun Movie() = DetailsResponseApi.Movie( adult = false, backdropPath = "/xRyINp9KfMLVjRiO5nCsoRDdvvF.jpg", - belongToCollection = null, + collection = null, budget = 63000000, genres = listOf( GenreResponseFactory.Movie.thriller, diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt index 26bc94b2e..8ad7d05bf 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost -import com.divinelink.core.model.home.MediaListSection import com.divinelink.core.navigation.route.Navigation typealias NavGraphExtension = NavGraphBuilder.( diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt index 3f0ccf584..ef1bb44a1 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt @@ -1,5 +1,6 @@ package com.divinelink.core.ui.components.details +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,6 +27,7 @@ fun CollectionBackdropImage( AsyncImage( modifier = modifier .alpha(0.5f) + .aspectRatio(16f / 9f) .fillMaxWidth(), model = ImageRequest.Builder(platformContext()) .memoryCachePolicy(CachePolicy.ENABLED) diff --git a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt index 4e0860438..c844aefaa 100644 --- a/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.divinelink.core.data.details.repository.DetailsRepository +import com.divinelink.core.model.media.MediaItem import com.divinelink.core.navigation.route.Navigation import com.divinelink.core.ui.blankslate.BlankSlateState import kotlinx.coroutines.flow.MutableStateFlow @@ -56,7 +57,10 @@ class CollectionsViewModel( loading = false, error = null, overview = details.overview, - movies = details.movies.sortedBy { it.releaseDate }, + movies = details.movies.sortedWith( + compareBy { it.releaseDate.isBlank() } + .thenBy { it.releaseDate.ifBlank { it.name } }, + ), ) } }, @@ -64,7 +68,7 @@ class CollectionsViewModel( _uiState.update { uiState -> uiState.copy( loading = false, - error = BlankSlateState.Generic, + error = BlankSlateState.Contact, ) } }, diff --git a/feature/details/src/commonMain/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index 7f1be5834..cff1cf6d6 100644 --- a/feature/details/src/commonMain/composeResources/values/strings.xml +++ b/feature/details/src/commonMain/composeResources/values/strings.xml @@ -81,7 +81,7 @@ Next episode air date Seasons - Part of %1$s + Part of the %1$s View the collection diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt index d7ca38e1e..0cd296eb4 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt @@ -1,7 +1,10 @@ package com.divinelink.feature.details.media.ui.forms.about import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.ButtonDefaults @@ -15,6 +18,7 @@ import androidx.compose.ui.draw.clip import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.model.details.Collection import com.divinelink.core.ui.components.details.CollectionBackdropImage +import com.divinelink.core.ui.conditional import com.divinelink.feature.details.resources.Res import com.divinelink.feature.details.resources.feature_details_part_of_collection import com.divinelink.feature.details.resources.feature_details_view_collection @@ -31,31 +35,42 @@ fun CollectionBanner( .clickable(onClick = onClick) .fillMaxWidth(), ) { - CollectionBackdropImage( - path = collection.backdropPath, - ) - - Text( - modifier = Modifier - .padding(MaterialTheme.dimensions.keyline_16) - .align(Alignment.TopCenter), - text = stringResource(Res.string.feature_details_part_of_collection, collection.name), - style = MaterialTheme.typography.titleLarge, - ) + if (collection.backdropPath?.isNotEmpty() == true) { + CollectionBackdropImage( + path = collection.backdropPath, + ) + } - TextButton( + Column( modifier = Modifier - .padding(bottom = MaterialTheme.dimensions.keyline_4) - .align(Alignment.BottomCenter), - onClick = onClick, - colors = ButtonDefaults.textButtonColors().copy( - contentColor = MaterialTheme.colorScheme.onSurface, - ), + .fillMaxWidth() + .conditional( + condition = collection.backdropPath?.isNotEmpty() == true, + ifTrue = { matchParentSize() }, + ifFalse = { fillMaxHeight() }, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, ) { Text( - text = stringResource(Res.string.feature_details_view_collection), - style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(MaterialTheme.dimensions.keyline_16), + text = stringResource(Res.string.feature_details_part_of_collection, collection.name), + style = MaterialTheme.typography.titleLarge, ) + + TextButton( + modifier = Modifier + .padding(bottom = MaterialTheme.dimensions.keyline_4), + onClick = onClick, + colors = ButtonDefaults.textButtonColors().copy( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = stringResource(Res.string.feature_details_view_collection), + style = MaterialTheme.typography.titleSmall, + ) + } } } }