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/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/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/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 1a1758160..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 @@ -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/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/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/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/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..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 @@ -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/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/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/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?, +) 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/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/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/CollectionBackdropImage.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt new file mode 100644 index 000000000..ef1bb44a1 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/CollectionBackdropImage.kt @@ -0,0 +1,41 @@ +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 +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) + .aspectRatio(16f / 9f) + .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/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/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..2809a58a4 --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsAction.kt @@ -0,0 +1,5 @@ +package com.divinelink.feature.collections + +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 new file mode 100644 index 000000000..9c284174b --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsUiState.kt @@ -0,0 +1,29 @@ +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( + id = route.id, + 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 new file mode 100644 index 000000000..c844aefaa --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/CollectionsViewModel.kt @@ -0,0 +1,78 @@ +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.model.media.MediaItem +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() { + + 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 { + 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.sortedWith( + compareBy { it.releaseDate.isBlank() } + .thenBy { it.releaseDate.ifBlank { it.name } }, + ), + ) + } + }, + onFailure = { + _uiState.update { uiState -> + uiState.copy( + loading = false, + error = BlankSlateState.Contact, + ) + } + }, + ) + } + } +} 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..d7c55369a --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsContent.kt @@ -0,0 +1,130 @@ +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 + +@Composable +fun SharedTransitionScope.CollectionsContent( + visibilityScope: AnimatedVisibilityScope, + uiState: CollectionsUiState, + onBackdropLoaded: () -> Unit, + toolbarProgress: (Float) -> Unit, + onSwitchPreferences: (SwitchPreferencesAction) -> Unit, + onAction: (CollectionsAction) -> Unit, + onNavigate: (Navigation) -> Unit, +) { + DetailCollapsibleContent( + visibilityScope = visibilityScope, + backdropPath = uiState.backdropPath, + posterPath = uiState.posterPath, + toolbarProgress = toolbarProgress, + onBackdropLoaded = onBackdropLoaded, + onNavigateToMediaPoster = { onNavigate(Navigation.MediaPosterRoute(it)) }, + headerContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + ) { + 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, + ) + } + }, + ) +} + +@Composable +@Previews +fun CollectionsContentPreview( + @PreviewParameter(CollectionsUiStateParameterProvider::class) state: CollectionsUiState, +) { + PreviewLocalProvider { + SharedTransitionScopeProvider { scope -> + scope.CollectionsContent( + visibilityScope = this, + 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 new file mode 100644 index 000000000..10ac345b8 --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/CollectionsScreen.kt @@ -0,0 +1,113 @@ +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.domain.components.SwitchViewButtonViewModel +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(), + switchViewButtonViewModel: SwitchViewButtonViewModel = 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 }, + onSwitchPreferences = switchViewButtonViewModel::onAction, + onAction = viewModel::onAction, + 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..fb9f4827d --- /dev/null +++ b/feature/collections/src/commonMain/kotlin/com/divinelink/feature/collections/ui/provider/CollectionsUiStateParameterProvider.kt @@ -0,0 +1,8 @@ +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/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index 1b37c93e8..cff1cf6d6 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 the %1$s + View the collection + Country Countries 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/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..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()) }, ) } } @@ -91,6 +93,24 @@ fun AboutFormContent( } } + aboutData.collection?.let { collection -> + item { + CollectionBanner( + collection = collection, + onClick = { + onNavigate( + Navigation.CollectionRoute( + id = collection.id, + name = collection.name, + backdropPath = collection.backdropPath, + posterPath = collection.posterPath, + ), + ) + }, + ) + } + } + 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..0cd296eb4 --- /dev/null +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/about/CollectionBanner.kt @@ -0,0 +1,76 @@ +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 +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.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 +import org.jetbrains.compose.resources.stringResource + +@Composable +fun CollectionBanner( + collection: Collection, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick) + .fillMaxWidth(), + ) { + if (collection.backdropPath?.isNotEmpty() == true) { + CollectionBackdropImage( + path = collection.backdropPath, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .conditional( + condition = collection.backdropPath?.isNotEmpty() == true, + ifTrue = { matchParentSize() }, + ifFalse = { fillMaxHeight() }, + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + 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, + ) + } + } + } +} 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")