diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc7348523..e3cf5e711 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { implementation(projects.feature.requestMedia) implementation(projects.feature.requests) implementation(projects.feature.search) + implementation(projects.feature.season) implementation(projects.feature.settings) implementation(projects.feature.tmdbAuth) implementation(projects.feature.userData) 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 c0ae11d51..b83d36593 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt @@ -23,6 +23,7 @@ import com.divinelink.feature.onboarding.navigation.modalOnboarding import com.divinelink.feature.profile.navigation.profileScreen import com.divinelink.feature.requests.ui.navigation.requestsScreen import com.divinelink.feature.search.navigation.searchScreen +import com.divinelink.feature.season.ui.navigation.seasonScreen import com.divinelink.feature.settings.navigation.about.aboutSettingsScreen import com.divinelink.feature.settings.navigation.account.accountSettingsScreen import com.divinelink.feature.settings.navigation.account.jellyseerrSettingsScreen @@ -288,6 +289,14 @@ val navigationModule = module { } } + single(named()) { + { navController, _ -> + seasonScreen( + 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 56fd64f0d..b1db68caf 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt @@ -19,6 +19,7 @@ import com.divinelink.feature.profile.ProfileViewModel import com.divinelink.feature.request.media.RequestMediaViewModel import com.divinelink.feature.requests.RequestsViewModel import com.divinelink.feature.search.ui.SearchViewModel +import com.divinelink.feature.season.SeasonViewModel import com.divinelink.feature.settings.app.account.AccountSettingsViewModel import com.divinelink.feature.settings.app.account.jellyseerr.JellyseerrSettingsViewModel import com.divinelink.feature.settings.app.appearance.AppearanceSettingsViewModel @@ -55,6 +56,7 @@ val appViewModelModule = module { viewModelOf(::DiscoverViewModel) viewModelOf(::SelectFilterViewModel) viewModelOf(::MediaListsViewModel) + viewModelOf(::SeasonViewModel) // Components viewModelOf(::SwitchViewButtonViewModel) 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 14dd3aa8e..b9dac390d 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 @@ -17,6 +17,7 @@ import com.divinelink.core.navigation.route.navigateToPoster import com.divinelink.core.navigation.route.navigateToRequests import com.divinelink.core.navigation.route.navigateToSearchFromHome import com.divinelink.core.navigation.route.navigateToSearchFromTab +import com.divinelink.core.navigation.route.navigateToSeason import com.divinelink.core.navigation.route.navigateToTMDBAuth import com.divinelink.core.navigation.route.navigateToUserData import com.divinelink.core.navigation.route.navigateToWebView @@ -62,6 +63,7 @@ fun NavController.findNavigation(route: Navigation) { Navigation.DiscoverRoute -> navigateToDiscover() is Navigation.MediaPosterRoute -> navigateToPoster(route) is Navigation.MediaListsRoute -> navigateToMediaLists(route) + is Navigation.SeasonRoute -> navigateToSeason(route) // This is from top level navigation Navigation.HomeRoute -> Unit diff --git a/app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/season/SeasonContentScreenshots.kt b/app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/season/SeasonContentScreenshots.kt new file mode 100644 index 000000000..ea9e8523e --- /dev/null +++ b/app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/season/SeasonContentScreenshots.kt @@ -0,0 +1,16 @@ +package com.divinelink.scenepeek.feature.season + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.divinelink.core.ui.Previews +import com.divinelink.feature.season.SeasonUiState +import com.divinelink.feature.season.ui.SeasonContentPreview +import com.divinelink.feature.season.ui.provider.SeasonUiStateParameterProvider + +@Previews +@Composable +fun SeasonContentScreenshots( + @PreviewParameter(SeasonUiStateParameterProvider::class) uiState: SeasonUiState, +) { + SeasonContentPreview(uiState) +} diff --git a/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/media/ProdMediaRepositoryTest.kt b/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/media/ProdMediaRepositoryTest.kt index 3afe5b02c..09229c3c9 100644 --- a/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/media/ProdMediaRepositoryTest.kt +++ b/core/data/src/androidHostTest/kotlin/com/divinelink/core/data/media/ProdMediaRepositoryTest.kt @@ -3,6 +3,8 @@ package com.divinelink.core.data.media import app.cash.turbine.test import com.divinelink.core.data.media.repository.MediaRepository import com.divinelink.core.data.media.repository.ProdMediaRepository +import com.divinelink.core.database.person.ProdPersonDao +import com.divinelink.core.fixtures.core.commons.ClockFactory import com.divinelink.core.fixtures.model.GenreFactory import com.divinelink.core.fixtures.model.media.MediaItemFactory import com.divinelink.core.fixtures.model.media.MediaItemFactory.toWizard @@ -13,6 +15,7 @@ import com.divinelink.core.network.Resource import com.divinelink.core.network.media.model.GenresListResponse import com.divinelink.core.testing.MainDispatcherRule import com.divinelink.core.testing.dao.TestMediaDao +import com.divinelink.core.testing.database.TestDatabaseFactory import com.divinelink.core.testing.factories.api.media.GenreResponseFactory import com.divinelink.core.testing.service.TestMediaService import io.kotest.matchers.shouldBe @@ -28,19 +31,25 @@ class ProdMediaRepositoryTest { withFavorite(true) } - private var mediaDao = TestMediaDao() - private var mediaService = TestMediaService() - @get:Rule val mainDispatcherRule = MainDispatcherRule() private val testDispatcher = mainDispatcherRule.testDispatcher + private var mediaDao = TestMediaDao() + private var personDao = ProdPersonDao( + clock = ClockFactory.augustFifteenth2021(), + database = TestDatabaseFactory.createInMemoryDatabase(), + dispatcher = testDispatcher, + ) + private var mediaService = TestMediaService() + private lateinit var repository: MediaRepository @Before fun setUp() { repository = ProdMediaRepository( dao = mediaDao.mock, + personDao = personDao, remote = mediaService.mock, dispatcher = testDispatcher, ) diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt index d5a8279ef..e04e14785 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/MediaRepository.kt @@ -3,6 +3,7 @@ package com.divinelink.core.data.media.repository import com.divinelink.core.model.Genre import com.divinelink.core.model.PaginationData import com.divinelink.core.model.details.Season +import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.discover.DiscoverFilter import com.divinelink.core.model.home.MediaListRequest import com.divinelink.core.model.media.MediaItem @@ -68,6 +69,11 @@ interface MediaRepository { fun fetchTvSeasons(id: Int): Flow>> + fun fetchSeason( + showId: Int, + seasonNumber: Int, + ): Flow> + /** * Add favorite [media] to local storage. */ @@ -87,4 +93,9 @@ interface MediaRepository { ): Result suspend fun fetchGenres(mediaType: MediaType): Flow>> + + fun fetchSeasonDetails( + showId: Int, + season: Int, + ): Flow> } diff --git a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt index a74c7c85b..9e3827b84 100644 --- a/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt +++ b/core/data/src/commonMain/kotlin/com/divinelink/core/data/media/repository/ProdMediaRepository.kt @@ -3,9 +3,12 @@ package com.divinelink.core.data.media.repository import com.divinelink.core.commons.data import com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.database.media.dao.MediaDao +import com.divinelink.core.database.media.mapper.map +import com.divinelink.core.database.person.PersonDao import com.divinelink.core.model.Genre import com.divinelink.core.model.PaginationData import com.divinelink.core.model.details.Season +import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.discover.DiscoverFilter import com.divinelink.core.model.home.MediaListRequest import com.divinelink.core.model.media.MediaItem @@ -14,6 +17,7 @@ import com.divinelink.core.model.search.MultiSearch import com.divinelink.core.model.sort.SortOption import com.divinelink.core.model.user.data.UserDataResponse import com.divinelink.core.network.Resource +import com.divinelink.core.network.media.mapper.details.map import com.divinelink.core.network.media.mapper.map import com.divinelink.core.network.media.model.GenresListResponse import com.divinelink.core.network.media.model.movie.map @@ -33,6 +37,7 @@ import kotlinx.coroutines.flow.map class ProdMediaRepository( private val remote: MediaService, private val dao: MediaDao, + private val personDao: PersonDao, private val dispatcher: DispatcherProvider, ) : MediaRepository { @@ -273,6 +278,15 @@ class ProdMediaRepository( Result.success(it) } + override fun fetchSeason( + showId: Int, + seasonNumber: Int, + ): Flow> = dao + .fetchSeason( + showId = showId, + seasonNumber = seasonNumber, + ).map { Result.success(it) } + override suspend fun fetchGenres(mediaType: MediaType): Flow>> = networkBoundResource( query = { dao.fetchGenres(mediaType) }, @@ -282,4 +296,49 @@ class ProdMediaRepository( }, shouldFetch = { it.isEmpty() }, ) + + override fun fetchSeasonDetails( + showId: Int, + season: Int, + ): Flow> = networkBoundResource( + query = { + combine( + dao.fetchEpisodes(showId = showId, season = season), + dao.fetchSeasonDetails(showId = showId, season = season), + personDao.fetchGuestStars(showId = showId, season = season), + ) { episodes, details, guestStars -> + details?.map( + episodes = episodes, + guestStars = guestStars, + ) + } + }, + fetch = { + remote.fetchSeason( + showId = showId, + season = season, + ).map { it.map() } + }, + saveFetchResult = { result -> + val data = result.data + + dao.insertSeasonDetails( + seasonDetails = data, + showId = showId, + seasonNumber = season, + ) + + dao.insertEpisodes(data.episodes) + + data.episodes.forEach { episode -> + personDao.insertGuestStars( + season = season, + showId = showId, + episode = episode.number, + guestStars = data.guestStars, + ) + } + }, + shouldFetch = { true }, + ) } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt index f32b0468c..ec1e70f19 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/MediaDao.kt @@ -1,8 +1,13 @@ +@file:Suppress("TooManyFunctions") + package com.divinelink.core.database.media.dao import com.divinelink.core.database.MediaItemEntity +import com.divinelink.core.database.season.SeasonDetailsEntity import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Episode import com.divinelink.core.model.details.Season +import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.jellyseerr.media.SeasonRequest import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaReference @@ -41,6 +46,11 @@ interface MediaDao { fun fetchSeasons(id: Int): Flow> + fun fetchSeason( + showId: Int, + seasonNumber: Int, + ): Flow + fun addToFavorites( mediaId: Int, mediaType: MediaType, @@ -62,4 +72,28 @@ interface MediaDao { mediaType: MediaType, genres: List, ) + + fun insertEpisodes(episodes: List) + + fun fetchEpisode( + showId: Int, + episodeNumber: Int, + seasonNumber: Int, + ): Episode + + fun fetchEpisodes( + showId: Int, + season: Int, + ): Flow> + + fun insertSeasonDetails( + seasonDetails: SeasonDetails, + showId: Int, + seasonNumber: Int, + ) + + fun fetchSeasonDetails( + season: Int, + showId: Int, + ): Flow } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt index 8d640d68c..978f42989 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/dao/ProdMediaDao.kt @@ -2,14 +2,20 @@ package com.divinelink.core.database.media.dao import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.mapToList +import app.cash.sqldelight.coroutines.mapToOne +import app.cash.sqldelight.coroutines.mapToOneOrNull import com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.database.Database import com.divinelink.core.database.MediaItemEntity import com.divinelink.core.database.SeasonEntity import com.divinelink.core.database.currentEpochSeconds import com.divinelink.core.database.media.mapper.map +import com.divinelink.core.database.season.EpisodeEntity +import com.divinelink.core.database.season.SeasonDetailsEntity import com.divinelink.core.model.Genre +import com.divinelink.core.model.details.Episode import com.divinelink.core.model.details.Season +import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.jellyseerr.media.JellyseerrStatus import com.divinelink.core.model.jellyseerr.media.SeasonRequest import com.divinelink.core.model.media.MediaItem @@ -198,6 +204,31 @@ class ProdMediaDao( } } + override fun fetchSeason( + showId: Int, + seasonNumber: Int, + ): Flow = database + .transactionWithResult { + database + .seasonEntityQueries + .fetchSeason(mediaId = showId.toLong(), seasonNumber = seasonNumber.toLong()) + .asFlow() + .mapToOne(context = dispatcher.io) + .map { entity -> + Season( + id = entity.id.toInt(), + name = entity.name, + overview = entity.overview, + posterPath = entity.posterPath, + airDate = entity.airDate, + episodeCount = entity.episodeCount.toInt(), + voteAverage = entity.voteAverage, + seasonNumber = entity.seasonNumber.toInt(), + status = JellyseerrStatus.from(entity.status), + ) + } + } + override fun insertSeasons( id: Int, seasons: List, @@ -265,4 +296,95 @@ class ProdMediaDao( ) } } + + override fun insertEpisodes(episodes: List) = database.transaction { + episodes.forEach { episode -> + database.episodeEntityQueries.insertEpisode( + EpisodeEntity( + id = episode.id.toLong(), + showId = episode.showId.toLong(), + overview = episode.overview, + name = episode.name, + runtime = episode.runtime, + episodeNumber = episode.number.toLong(), + seasonNumber = episode.seasonNumber.toLong(), + airDate = episode.airDate, + stillPath = episode.stillPath, + voteAverage = episode.voteAverage?.toDouble() ?: 0.0, + ), + ) + } + } + + override fun fetchEpisode( + showId: Int, + episodeNumber: Int, + seasonNumber: Int, + ): Episode = database + .transactionWithResult { + database + .episodeEntityQueries + .fetchEpisode( + showId = showId.toLong(), + seasonNumber = seasonNumber.toLong(), + episodeNumber = episodeNumber.toLong(), + ) + .executeAsOne() + .map() + } + + override fun fetchEpisodes( + showId: Int, + season: Int, + ): Flow> = database + .transactionWithResult { + database + .episodeEntityQueries + .fetchEpisodes( + showId = showId.toLong(), + seasonNumber = season.toLong(), + ) + .asFlow() + .mapToList(dispatcher.io) + .map { entities -> + entities.map { it.map() } + } + } + + override fun insertSeasonDetails( + seasonDetails: SeasonDetails, + showId: Int, + seasonNumber: Int, + ) = database + .transaction { + database.seasonDetailsEntityQueries.insertSeasonDetails( + SeasonDetailsEntity = SeasonDetailsEntity( + id = seasonDetails.id.toLong(), + showId = showId.toLong(), + name = seasonDetails.name, + overview = seasonDetails.overview, + posterPath = seasonDetails.posterPath, + airDate = seasonDetails.airDate, + episodeCount = seasonDetails.episodeCount.toLong(), + voteAverage = seasonDetails.voteAverage, + seasonNumber = seasonNumber.toLong(), + runtime = seasonDetails.totalRuntime, + ), + ) + } + + override fun fetchSeasonDetails( + season: Int, + showId: Int, + ): Flow = database + .transactionWithResult { + database + .seasonDetailsEntityQueries + .fetchSeasonDetails( + showId = showId.toLong(), + seasonNumber = season.toLong(), + ) + .asFlow() + .mapToOneOrNull(dispatcher.io) + } } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt new file mode 100644 index 000000000..46143d688 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt @@ -0,0 +1,19 @@ +package com.divinelink.core.database.media.mapper + +import com.divinelink.core.database.season.EpisodeEntity +import com.divinelink.core.model.details.Episode + +fun EpisodeEntity.map() = Episode( + id = id.toInt(), + name = name, + airDate = airDate, + overview = overview, + runtime = runtime, + number = episodeNumber.toInt(), + seasonNumber = seasonNumber.toInt(), + showId = showId.toInt(), + stillPath = stillPath, + voteAverage = voteAverage.toString(), + crew = emptyList(), + guestStars = emptyList(), +) diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/SeasonDetailsEntityMapper.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/SeasonDetailsEntityMapper.kt new file mode 100644 index 000000000..9ff026c00 --- /dev/null +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/SeasonDetailsEntityMapper.kt @@ -0,0 +1,22 @@ +package com.divinelink.core.database.media.mapper + +import com.divinelink.core.database.season.SeasonDetailsEntity +import com.divinelink.core.model.details.Episode +import com.divinelink.core.model.details.Person +import com.divinelink.core.model.details.SeasonDetails + +fun SeasonDetailsEntity.map( + episodes: List, + guestStars: List, +) = SeasonDetails( + id = id.toInt(), + name = name, + overview = overview, + posterPath = posterPath, + airDate = airDate, + episodeCount = episodeCount.toInt(), + voteAverage = voteAverage, + episodes = episodes, + totalRuntime = runtime, + guestStars = guestStars, +) diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/PersonDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/PersonDao.kt index a6b0ceac4..f7e809163 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/PersonDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/PersonDao.kt @@ -3,6 +3,7 @@ package com.divinelink.core.database.person import app.cash.sqldelight.db.QueryResult import com.divinelink.core.database.person.credits.PersonCastCreditEntity import com.divinelink.core.database.person.credits.PersonCrewCreditEntity +import com.divinelink.core.model.details.Person import kotlinx.coroutines.flow.Flow interface PersonDao { @@ -36,4 +37,16 @@ interface PersonDao { fun insertPersonCredits(id: Long) fun insertPersonCastCredits(cast: List) fun insertPersonCrewCredits(crew: List) + + fun insertGuestStars( + showId: Int, + season: Int, + episode: Int, + guestStars: List, + ) + + fun fetchGuestStars( + showId: Int, + season: Int, + ): Flow> } diff --git a/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/ProdPersonDao.kt b/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/ProdPersonDao.kt index 126b05b14..cc2b06458 100644 --- a/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/ProdPersonDao.kt +++ b/core/database/src/commonMain/kotlin/com/divinelink/core/database/person/ProdPersonDao.kt @@ -5,14 +5,20 @@ import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToOneOrNull import com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.database.Database +import com.divinelink.core.database.cast.PersonEntity +import com.divinelink.core.database.cast.PersonRoleEntity import com.divinelink.core.database.currentEpochSeconds import com.divinelink.core.database.person.credits.CastCreditsWithMedia import com.divinelink.core.database.person.credits.CrewCreditsWithMedia import com.divinelink.core.database.person.credits.PersonCastCreditEntity import com.divinelink.core.database.person.credits.PersonCreditsEntity import com.divinelink.core.database.person.credits.PersonCrewCreditEntity +import com.divinelink.core.model.credits.PersonRole +import com.divinelink.core.model.details.Person +import com.divinelink.core.model.person.Gender import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlin.time.Clock class ProdPersonDao( @@ -138,4 +144,93 @@ class ProdPersonDao( .crewCreditsWithMedia(id) .asFlow() .mapToList(context = dispatcher.io) + + override fun insertGuestStars( + showId: Int, + season: Int, + episode: Int, + guestStars: List, + ) { + database.transaction { + val insertedPersonIds = mutableSetOf() + val insertedCreditIds = mutableSetOf() + + guestStars.forEach { person -> + if (person.id !in insertedPersonIds) { + database.personEntityQueries.insertPerson( + PersonEntity( + id = person.id, + name = person.name, + originalName = person.name, + profilePath = person.profilePath, + knownForDepartment = person.knownForDepartment, + gender = person.gender.value.toLong(), + ), + ) + insertedPersonIds += person.id + } + + person.role + .filterIsInstance() + .forEach { role -> + if (role.creditId !in insertedCreditIds) { + database.personRoleEntityQueries.insertRole( + PersonRoleEntity( + creditId = role.creditId, + character = role.character, + castId = person.id, + ), + ) + + database.seasonGuestStarRoleEntityQueries.insertSeasonGuestStarRole( + showId = showId.toLong(), + season = season.toLong(), + creditId = role.creditId, + episode = episode.toLong(), + episodeCount = role.totalEpisodes?.toLong(), + displayOrder = role.order?.toLong() ?: -1, + ) + insertedCreditIds += role.creditId + } + } + } + } + } + + override fun fetchGuestStars( + showId: Int, + season: Int, + ): Flow> = database + .transactionWithResult { + database + .seasonGuestStarRoleEntityQueries + .fetchSeasonGuestStars( + season = season.toLong(), + showId = showId.toLong(), + ) + .asFlow() + .mapToList(dispatcher.io) + .map { entities -> + entities + .groupBy { it.id } + .map { (personId, roles) -> + val firstRole = roles.first() + Person( + id = personId, + name = firstRole.name, + profilePath = firstRole.profilePath, + gender = Gender.from(firstRole.gender.toInt()), + knownForDepartment = firstRole.knownForDepartment, + role = listOf( + PersonRole.SeriesActor( + character = firstRole.character, + creditId = firstRole.creditId, + totalEpisodes = firstRole.episodeCount?.toInt(), + order = firstRole.displayOrder.toInt(), + ), + ), + ) + } + } + } } diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/SeasonEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/SeasonEntity.sq index 1c3830b35..c8dc67759 100644 --- a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/SeasonEntity.sq +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/SeasonEntity.sq @@ -32,6 +32,11 @@ SELECT * FROM SeasonEntity WHERE mediaId = ? ORDER BY seasonNumber; +fetchSeason: +SELECT * FROM SeasonEntity +WHERE mediaId = ? AND seasonNumber = ? +LIMIT 1; + updateSeasonStatus: UPDATE SeasonEntity SET status = ? diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeEntity.sq new file mode 100644 index 000000000..ad16148cb --- /dev/null +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeEntity.sq @@ -0,0 +1,42 @@ +CREATE TABLE EpisodeEntity ( + id INTEGER NOT NULL, + showId INTEGER NOT NULL, + overview TEXT NOT NULL, + name TEXT NOT NULL, + runtime TEXT, + episodeNumber INTEGER NOT NULL, + seasonNumber INTEGER NOT NULL, + airDate TEXT, + stillPath TEXT, + voteAverage REAL NOT NULL, + UNIQUE(id, seasonNumber, showId) +); + +insertEpisode: +INSERT OR REPLACE INTO EpisodeEntity ( + id, + showId, + overview, + name, + runtime, + episodeNumber, + seasonNumber, + airDate, + stillPath, + voteAverage +) +VALUES ?; + +fetchEpisodesForSeason: +SELECT * FROM EpisodeEntity +WHERE showId = ? AND seasonNumber = ? +ORDER BY episodeNumber; + +fetchEpisodes: +SELECT * FROM EpisodeEntity +WHERE showId = ? AND seasonNumber = ?; + +fetchEpisode: +SELECT * FROM EpisodeEntity +WHERE showId = ? AND seasonNumber = ? AND episodeNumber = ? +LIMIT 1; diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonDetailsEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonDetailsEntity.sq new file mode 100644 index 000000000..3f9da17b3 --- /dev/null +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonDetailsEntity.sq @@ -0,0 +1,32 @@ +CREATE TABLE SeasonDetailsEntity ( + id INTEGER NOT NULL, + showId INTEGER NOT NULL, + name TEXT NOT NULL, + overview TEXT NOT NULL, + posterPath TEXT, + airDate TEXT, + episodeCount INTEGER NOT NULL, + voteAverage REAL NOT NULL, + seasonNumber INTEGER NOT NULL, + runtime TEXT, + UNIQUE(id, seasonNumber) +); + +insertSeasonDetails: +INSERT OR REPLACE INTO SeasonDetailsEntity ( + id, + showId, + name, + overview, + posterPath, + airDate, + episodeCount, + voteAverage, + seasonNumber, + runtime +) +VALUES ?; + +fetchSeasonDetails: +SELECT * FROM SeasonDetailsEntity +WHERE showId = ? AND seasonNumber = ?; diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonGuestStarRoleEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonGuestStarRoleEntity.sq new file mode 100644 index 000000000..b84c3a5b2 --- /dev/null +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonGuestStarRoleEntity.sq @@ -0,0 +1,29 @@ +CREATE TABLE SeasonGuestStarRoleEntity( + showId INTEGER NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + creditId TEXT NOT NULL REFERENCES PersonRoleEntity(creditId), + episodeCount INTEGER, + displayOrder INTEGER NOT NULL, + PRIMARY KEY (showId, season, creditId) +); + +insertSeasonGuestStarRole: +INSERT OR REPLACE INTO SeasonGuestStarRoleEntity(showId, season, episode, creditId, episodeCount, displayOrder) +VALUES (?,?, ?,?,?, ?); + +fetchSeasonGuestStars: +SELECT DISTINCT p.*, r.creditId, r.character, sgsr.displayOrder, sgsr.episodeCount +FROM PersonEntity p +JOIN PersonRoleEntity r ON p.id = r.castId +JOIN SeasonGuestStarRoleEntity sgsr ON r.creditId = sgsr.creditId +WHERE sgsr.season = :season AND sgsr.showId = :showId +ORDER BY sgsr.episodeCount DESC, sgsr.displayOrder ASC; + +fetchGuestStarsForEpisode: +SELECT DISTINCT p.*, r.creditId, r.character, sgsr.displayOrder, sgsr.episodeCount +FROM PersonEntity p +JOIN PersonRoleEntity r ON p.id = r.castId +JOIN SeasonGuestStarRoleEntity sgsr ON r.creditId = sgsr.creditId +WHERE sgsr.season = :season AND sgsr.showId = :showId AND sgsr.episode = :episode +ORDER BY sgsr.episodeCount DESC, sgsr.displayOrder ASC; \ No newline at end of file diff --git a/core/database/src/commonMain/sqldelight/migrations/8.sqm b/core/database/src/commonMain/sqldelight/migrations/8.sqm index 4903d5844..b94b4f1cf 100644 --- a/core/database/src/commonMain/sqldelight/migrations/8.sqm +++ b/core/database/src/commonMain/sqldelight/migrations/8.sqm @@ -26,4 +26,42 @@ CREATE TABLE ShowCastRoleEntity( episodeCount INTEGER NOT NULL, creditOrder INTEGER NOT NULL, PRIMARY KEY (showId, creditId) +); + +CREATE TABLE SeasonDetailsEntity ( + id INTEGER NOT NULL, + showId INTEGER NOT NULL, + name TEXT NOT NULL, + overview TEXT NOT NULL, + posterPath TEXT, + airDate TEXT, + episodeCount INTEGER NOT NULL, + voteAverage REAL NOT NULL, + seasonNumber INTEGER NOT NULL, + runtime TEXT, + UNIQUE(id, seasonNumber) +); + +CREATE TABLE EpisodeEntity ( + id INTEGER NOT NULL, + showId INTEGER NOT NULL, + overview TEXT NOT NULL, + name TEXT NOT NULL, + runtime TEXT, + episodeNumber INTEGER NOT NULL, + seasonNumber INTEGER NOT NULL, + airDate TEXT, + stillPath TEXT, + voteAverage REAL NOT NULL, + UNIQUE(id, seasonNumber, showId) +); + +CREATE TABLE SeasonGuestStarRoleEntity( + showId INTEGER NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + creditId TEXT NOT NULL REFERENCES PersonRoleEntity(creditId), + episodeCount INTEGER, + displayOrder INTEGER NOT NULL, + PRIMARY KEY (season, creditId) ); \ No newline at end of file diff --git a/core/model/src/commonMain/composeResources/values/strings.xml b/core/model/src/commonMain/composeResources/values/strings.xml index 7e1c5ef08..34372636d 100644 --- a/core/model/src/commonMain/composeResources/values/strings.xml +++ b/core/model/src/commonMain/composeResources/values/strings.xml @@ -30,6 +30,7 @@ Female Non binary + Episodes About Seasons Cast @@ -38,6 +39,8 @@ Movies TV Shows + Guest stars + Unknown Requested Pending diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/credits/PersonRole.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/credits/PersonRole.kt index 77b157a45..12f9e589b 100644 --- a/core/model/src/commonMain/kotlin/com/divinelink/core/model/credits/PersonRole.kt +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/credits/PersonRole.kt @@ -8,8 +8,9 @@ sealed class PersonRole(val title: String?) { @Serializable data class SeriesActor( val character: String, - val creditId: String? = null, + val creditId: String, val totalEpisodes: Int? = null, + val order: Int? = null, ) : PersonRole(character) @Serializable diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt new file mode 100644 index 000000000..e5c67dab5 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt @@ -0,0 +1,19 @@ +package com.divinelink.core.model.details + +import kotlinx.serialization.Serializable + +@Serializable +data class Episode( + val id: Int, + val name: String, + val airDate: String?, + val overview: String, + val runtime: String?, + val number: Int, + val seasonNumber: Int, + val showId: Int, + val stillPath: String?, + val voteAverage: String?, + val crew: List, + val guestStars: List, +) diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/SeasonDetails.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/SeasonDetails.kt new file mode 100644 index 000000000..ce402b157 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/SeasonDetails.kt @@ -0,0 +1,17 @@ +package com.divinelink.core.model.details + +import kotlinx.serialization.Serializable + +@Serializable +data class SeasonDetails( + val id: Int, + val name: String, + val overview: String, + val posterPath: String?, + val airDate: String?, + val episodeCount: Int, + val voteAverage: Double, + val episodes: List, + val totalRuntime: String?, + val guestStars: List, +) diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonData.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonData.kt new file mode 100644 index 000000000..a8343b5ca --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonData.kt @@ -0,0 +1,31 @@ +package com.divinelink.core.model.details.season + +import com.divinelink.core.model.details.Episode +import com.divinelink.core.model.details.Person + +data class SeasonInformation( + val totalEpisodes: Int, + val totalRuntime: String?, + val firstAirDate: String?, + val lastAirDate: String?, + val airedEpisodes: Int?, +) + +sealed interface SeasonData { + data class Episodes( + val episodes: List, + ) : SeasonData + + data class About( + val overview: String?, + val information: SeasonInformation, + ) : SeasonData + + data class Cast( + val cast: List, + ) : SeasonData + + data class GuestStars( + val cast: List, + ) : SeasonData +} diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonForms.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonForms.kt new file mode 100644 index 000000000..59582120b --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonForms.kt @@ -0,0 +1,11 @@ +package com.divinelink.core.model.details.season + +import com.divinelink.core.model.tab.SeasonTab + +typealias SeasonForms = Map> + +sealed interface SeasonForm { + data object Loading : SeasonForm + data object Error : SeasonForm + data class Content(val data: T) : SeasonForm +} diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/SeasonTab.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/SeasonTab.kt new file mode 100644 index 000000000..7e821164d --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/SeasonTab.kt @@ -0,0 +1,48 @@ +package com.divinelink.core.model.tab + +import com.divinelink.core.model.resources.Res +import com.divinelink.core.model.resources.core_model_tab_about +import com.divinelink.core.model.resources.core_model_tab_cast +import com.divinelink.core.model.resources.core_model_tab_episodes +import com.divinelink.core.model.resources.core_model_tab_guest_stars +import org.jetbrains.compose.resources.StringResource + +sealed class SeasonTab( + override val order: Int, + override val titleRes: StringResource, + override val value: String, +) : Tab(order, value, titleRes) { + + data object Episodes : SeasonTab( + order = 0, + value = "episodes", + titleRes = Res.string.core_model_tab_episodes, + ) + + data object About : SeasonTab( + order = 1, + value = "about", + titleRes = Res.string.core_model_tab_about, + ) + + data object Cast : SeasonTab( + order = 2, + value = "guest_stars", + titleRes = Res.string.core_model_tab_cast, + ) + + data object GuestStars : SeasonTab( + order = 3, + value = "guest_stars", + titleRes = Res.string.core_model_tab_guest_stars, + ) + + companion object { + val entries + get() = listOf( + Episodes, + About, + GuestStars, + ) + } +} diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt new file mode 100644 index 000000000..99aad8dc5 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt @@ -0,0 +1,21 @@ +package com.divinelink.core.network.media.mapper.details + +import com.divinelink.core.model.details.Episode +import com.divinelink.core.network.media.model.details.season.EpisodeResponse +import com.divinelink.core.network.media.model.details.toHourMinuteFormat +import com.divinelink.core.network.media.model.details.toPerson + +fun EpisodeResponse.map() = Episode( + id = id, + name = name, + airDate = airDate, + overview = overview, + runtime = runtime.toHourMinuteFormat(), + seasonNumber = seasonNumber, + showId = showId, + stillPath = stillPath, + voteAverage = voteAverage, + number = episodeNumber, + crew = crew.map(), + guestStars = guestStars.map { it.toPerson() }, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt new file mode 100644 index 000000000..1c13ae6c7 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt @@ -0,0 +1,56 @@ +package com.divinelink.core.network.media.mapper.details + +import com.divinelink.core.model.credits.PersonRole +import com.divinelink.core.model.details.Person +import com.divinelink.core.model.details.SeasonDetails +import com.divinelink.core.model.person.Gender +import com.divinelink.core.network.media.model.details.season.EpisodeResponse +import com.divinelink.core.network.media.model.details.season.SeasonDetailsResponse +import com.divinelink.core.network.media.model.details.toHourMinuteFormat + +fun SeasonDetailsResponse.map(): SeasonDetails = SeasonDetails( + id = id, + name = name, + overview = overview, + airDate = airDate, + episodeCount = episodes.count(), + posterPath = posterPath, + voteAverage = voteAverage, + totalRuntime = episodes + .filter { it.runtime != null } + .sumOf { it.runtime!! } + .toHourMinuteFormat(), + episodes = episodes.map { it.map() }, + guestStars = aggregateGuestStars(episodes), +) + +private fun aggregateGuestStars(allEpisodes: List): List = allEpisodes + .flatMap { it.guestStars } + .groupBy { it.id } + .map { (id, cast) -> + val firstCast = cast.first() + + val roles = cast + .groupingBy { it } + .eachCount() + + Person( + id = id, + name = firstCast.name, + profilePath = firstCast.profilePath, + gender = Gender.from(firstCast.gender), + knownForDepartment = firstCast.knownForDepartment, + role = roles.map { (cast, count) -> + PersonRole.SeriesActor( + character = cast.character, + creditId = cast.creditId, + totalEpisodes = count, + order = cast.order, + ) + }, + ) + } + .sortedByDescending { + it.role.sumOf { role -> (role as? PersonRole.SeriesActor)?.totalEpisodes ?: 0 } + } + .distinctBy { it.id } 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 c95755f65..2088ed88a 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 @@ -197,7 +197,7 @@ private fun DetailsResponseApi.TV.toDomainTVShow(): MediaDetails = TV( private fun List.toActors(): List = this.map(CastApi::toPerson) -private fun CastApi.toPerson(): Person = Person( +fun CastApi.toPerson(): Person = Person( id = this.id, name = this.name, profilePath = this.profilePath, @@ -212,17 +212,24 @@ private fun CastApi.toPerson(): Person = Person( ), ) } - is CastApi.TV -> listOf(PersonRole.Unknown) + is CastApi.TV -> listOf( + PersonRole.SeriesActor( + character = character, + creditId = creditId, + order = order, + ), + ) }, ) @Suppress("MagicNumber") -private fun Int?.toHourMinuteFormat(): String? { +fun Int?.toHourMinuteFormat(): String? { return this?.let { minutes -> val hours = minutes / 60 val remainingMinutes = minutes % 60 return when { - hours > 0 -> "${hours}h ${remainingMinutes}m" + hours > 0 && remainingMinutes > 0 -> "${hours}h ${remainingMinutes}m" + hours > 0 -> "${hours}h" remainingMinutes > 0 -> "${remainingMinutes}m" else -> null } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/credits/CastApi.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/credits/CastApi.kt index 75f43b9a0..44a64c889 100644 --- a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/credits/CastApi.kt +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/credits/CastApi.kt @@ -13,6 +13,9 @@ sealed class CastApi { abstract val knownForDepartment: String? abstract val gender: Int + @SerialName("credit_id") + abstract val creditId: String + @Serializable data class Movie( val adult: Boolean, @@ -25,7 +28,7 @@ sealed class CastApi { val popularity: Double, @SerialName("profile_path") override val profilePath: String?, override val character: String, - @SerialName("credit_id") val creditId: String, + @SerialName("credit_id") override val creditId: String, override val order: Int, ) : CastApi() @@ -40,7 +43,7 @@ sealed class CastApi { val popularity: Double, @SerialName("profile_path") override val profilePath: String?, override val character: String, - @SerialName("credit_id") val creditId: String, + @SerialName("credit_id") override val creditId: String, override val order: Int, ) : CastApi() } diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/EpisodeResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/EpisodeResponse.kt new file mode 100644 index 000000000..5642c1ace --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/EpisodeResponse.kt @@ -0,0 +1,24 @@ +package com.divinelink.core.network.media.model.details.season + +import com.divinelink.core.network.media.model.details.credits.CastApi +import com.divinelink.core.network.media.model.details.credits.CrewApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class EpisodeResponse( + val id: Int, + @SerialName("air_date") val airDate: String?, + @SerialName("episode_type") val episodeType: String, + val name: String, + val overview: String, + val runtime: Int?, + @SerialName("episode_number") val episodeNumber: Int, + @SerialName("season_number") val seasonNumber: Int, + @SerialName("show_id") val showId: Int, + @SerialName("still_path") val stillPath: String?, + @SerialName("vote_average") val voteAverage: String?, + @SerialName("vote_count") val voteCount: String, + val crew: List, + @SerialName("guest_stars") val guestStars: List, +) diff --git a/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt new file mode 100644 index 000000000..87bd83645 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt @@ -0,0 +1,15 @@ +package com.divinelink.core.network.media.model.details.season + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SeasonDetailsResponse( + val id: Int, + val name: String, + val overview: String, + @SerialName("air_date") val airDate: String?, + @SerialName("poster_path") val posterPath: String?, + @SerialName("vote_average") val voteAverage: Double, + val episodes: List, +) 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 1f9dc23ff..ffb7daa16 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 @@ -9,6 +9,7 @@ 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.DetailsResponseApi import com.divinelink.core.network.media.model.details.reviews.ReviewsResponseApi +import com.divinelink.core.network.media.model.details.season.SeasonDetailsResponse import com.divinelink.core.network.media.model.details.videos.VideosResponseApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi import com.divinelink.core.network.media.model.details.watchlist.SubmitOnAccountResponse @@ -77,4 +78,9 @@ interface MediaService { fun findById(externalId: String): Flow suspend fun fetchGenres(mediaType: MediaType): Result + + suspend fun fetchSeason( + showId: Int, + season: 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 2a7d75a38..60ac6d54d 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.media.model.MediaRequestApi import com.divinelink.core.network.media.model.credits.AggregateCreditsApi 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 import com.divinelink.core.network.media.model.details.videos.VideosResponseApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestApi import com.divinelink.core.network.media.model.details.watchlist.AddToWatchlistRequestBodyApi @@ -30,6 +31,7 @@ import com.divinelink.core.network.media.util.buildFetchDetailsUrl import com.divinelink.core.network.media.util.buildFetchMediaListUrl import com.divinelink.core.network.media.util.buildFindByIdUrl import com.divinelink.core.network.media.util.buildGenreUrl +import com.divinelink.core.network.media.util.buildSeasonDetailsUrl import com.divinelink.core.network.runCatchingWithNetworkRetry import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -253,4 +255,16 @@ class ProdMediaService(private val restClient: TMDbClient) : MediaService { override suspend fun fetchGenres(mediaType: MediaType): Result = runCatching { restClient.get(url = buildGenreUrl(mediaType)) } + + override suspend fun fetchSeason( + showId: Int, + season: Int, + ): Result = runCatching { + restClient.get( + url = buildSeasonDetailsUrl( + showId = showId, + seasonNumber = season, + ), + ) + } } 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 912f38a19..9b109773c 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 @@ -130,3 +130,16 @@ fun buildFetchMediaListUrl( } } }.toString() + +fun buildSeasonDetailsUrl( + showId: Int, + seasonNumber: Int, +): String = buildUrl { + protocol = URLProtocol.HTTPS + host = Routes.TMDb.HOST + encodedPath = Routes.TMDb.V3 + "/tv/$showId/season/$seasonNumber" + + parameters.apply { + append("language", "en") + } +}.toString() 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 5be81e3c6..f4397e942 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 @@ -67,6 +67,14 @@ sealed interface Navigation { val isFavorite: Boolean?, ) : Navigation + @Serializable + data class SeasonRoute( + val showId: Int, + val seasonNumber: Int, + val backdropPath: String?, + val title: String, + ) : Navigation + @Serializable data object ProfileRoute : Navigation diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/SeasonRoute.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/SeasonRoute.kt new file mode 100644 index 000000000..ed3c01583 --- /dev/null +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/SeasonRoute.kt @@ -0,0 +1,7 @@ +package com.divinelink.core.navigation.route + +import androidx.navigation.NavController + +fun NavController.navigateToSeason(route: Navigation.SeasonRoute) = navigate( + route = route, +) diff --git a/core/ui/src/androidMain/kotlin/com/divinelink/core/ui/credit/PersonItemPreviews.kt b/core/ui/src/androidMain/kotlin/com/divinelink/core/ui/credit/PersonItemPreviews.kt index 44bf53ff6..ceabf9fb0 100644 --- a/core/ui/src/androidMain/kotlin/com/divinelink/core/ui/credit/PersonItemPreviews.kt +++ b/core/ui/src/androidMain/kotlin/com/divinelink/core/ui/credit/PersonItemPreviews.kt @@ -31,14 +31,20 @@ fun PersonItemPreview() { PersonRole.SeriesActor( character = "Character 1", totalEpisodes = 10, + creditId = "credit_1", + order = 1, ), PersonRole.SeriesActor( character = "Character 2", totalEpisodes = 5, + creditId = "credit_2", + order = 2, ), PersonRole.SeriesActor( character = "Character 3", totalEpisodes = 5, + creditId = "credit_3", + order = 3, ), ), ), @@ -56,14 +62,20 @@ fun PersonItemPreview() { PersonRole.SeriesActor( character = "Character 1", totalEpisodes = 10, + creditId = "credit_1", + order = 1, ), PersonRole.SeriesActor( character = "Character 2", totalEpisodes = 5, + creditId = "credit_2", + order = 2, ), PersonRole.SeriesActor( character = "Character 3", totalEpisodes = 5, + creditId = "credit_3", + order = 3, ), ), ), diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 5c4af8fed..4c3b9fabd 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -6,6 +6,8 @@ Discover Share error details + Information + Approve Decline @@ -102,6 +104,11 @@ Episodes Status + Total time + Last air date + First air date + Aired episodes + Request 1 season Request %1$s seasons @@ -186,6 +193,11 @@ %1$s %2$s + No cast available + We don't have any cast information for %1$s + No guest appearances + There are no guest appearances for %1$s + Navigate Up Button Movie image diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/SimpleInformationRow.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/SimpleInformationRow.kt new file mode 100644 index 000000000..3ffe1eccc --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/SimpleInformationRow.kt @@ -0,0 +1,33 @@ +package com.divinelink.core.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.divinelink.core.designsystem.theme.dimensions + +@Composable +fun SimpleInformationRow( + title: String, + data: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + ) { + Text( + modifier = Modifier.weight(0.35f), + text = title, + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + modifier = Modifier.weight(0.65f), + text = data, + style = MaterialTheme.typography.bodyMedium, + ) + } +} 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 new file mode 100644 index 000000000..86b617c68 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt @@ -0,0 +1,94 @@ +package com.divinelink.core.ui.collapsingheader.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.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.IntOffset +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.ImageQuality +import com.divinelink.core.ui.SharedElementKeys +import com.divinelink.core.ui.TestTags +import com.divinelink.core.ui.coil.PosterImage +import com.divinelink.core.ui.collapsingheader.CollapsingHeaderState +import com.divinelink.core.ui.components.details.BackdropImage +import com.divinelink.core.ui.conditional +import com.divinelink.core.ui.mediaImageDropShadow +import kotlin.math.roundToInt + +@Composable +fun SharedTransitionScope.CollapsibleHeaderContent( + collapsingHeaderState: CollapsingHeaderState, + backdropPath: String?, + posterPath: String?, + onBackdropLoaded: () -> Unit, + visibilityScope: AnimatedVisibilityScope, + onNavigateToMediaPoster: (String) -> Unit, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier.offset { + IntOffset( + x = 0, + y = -collapsingHeaderState.translation.roundToInt(), + ) + }, + ) { + BackdropImage( + path = backdropPath, + onBackdropLoaded = onBackdropLoaded, + ) + + Column( + modifier = Modifier + .verticalScroll(state = rememberScrollState()) + .testTag(TestTags.Details.COLLAPSIBLE_CONTENT) + .padding(MaterialTheme.dimensions.keyline_16) + .conditional( + condition = backdropPath?.isNotBlank() == true, + ifTrue = { padding(top = MaterialTheme.dimensions.keyline_56) }, + ) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + verticalAlignment = Alignment.CenterVertically, + ) { + PosterImage( + modifier = Modifier + .sharedElement( + sharedContentState = rememberSharedContentState( + SharedElementKeys.MediaPoster(posterPath ?: ""), + ), + animatedVisibilityScope = visibilityScope, + ) + .mediaImageDropShadow() + .height(MaterialTheme.dimensions.posterSizeSmall) + .aspectRatio(2f / 3f), + path = posterPath, + quality = ImageQuality.QUALITY_342, + onClick = { onNavigateToMediaPoster(it) }, + ) + + content() + } + } + } +} diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/DetailCollapsibleContent.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/DetailCollapsibleContent.kt new file mode 100644 index 000000000..987738e80 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/DetailCollapsibleContent.kt @@ -0,0 +1,58 @@ +package com.divinelink.core.ui.collapsingheader.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.ui.TestTags +import com.divinelink.core.ui.collapsingheader.CollapsingHeaderLayout +import com.divinelink.core.ui.collapsingheader.rememberCollapsingHeaderState + +@Composable +fun SharedTransitionScope.DetailCollapsibleContent( + visibilityScope: AnimatedVisibilityScope, + backdropPath: String?, + posterPath: String?, + toolbarProgress: (Float) -> Unit, + onBackdropLoaded: () -> Unit, + onNavigateToMediaPoster: (String) -> Unit, + headerContent: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + + val collapsingHeaderState = rememberCollapsingHeaderState( + collapsedHeight = with(density) { MaterialTheme.dimensions.keyline_0.toPx() }, + initialExpandedHeight = with(density) { 400.dp.toPx() }, + ) + + LaunchedEffect(collapsingHeaderState.progress) { + toolbarProgress(collapsingHeaderState.progress) + } + + CollapsingHeaderLayout( + modifier = Modifier + .testTag(TestTags.Details.COLLAPSIBLE_LAYOUT) + .fillMaxSize(), + state = collapsingHeaderState, + headerContent = { + CollapsibleHeaderContent( + collapsingHeaderState = collapsingHeaderState, + backdropPath = backdropPath, + posterPath = posterPath, + onBackdropLoaded = onBackdropLoaded, + visibilityScope = visibilityScope, + onNavigateToMediaPoster = onNavigateToMediaPoster, + content = { headerContent() }, + ) + }, + body = { content() }, + ) +} diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/AppTopAppBar.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/AppTopAppBar.kt index d0730e098..645bcb382 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/AppTopAppBar.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/AppTopAppBar.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -29,11 +30,12 @@ import com.divinelink.core.ui.getString import com.divinelink.core.ui.resources.core_ui_navigate_up_button_content_description import org.jetbrains.compose.resources.stringResource -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AppTopAppBar( modifier: Modifier = Modifier, text: UIText, + subtitle: UIText? = null, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(), topAppBarColors: TopAppBarColors = TopAppBarDefaults.topAppBarColors( scrolledContainerColor = MaterialTheme.colorScheme.surface, @@ -72,6 +74,20 @@ fun AppTopAppBar( overflow = TextOverflow.Ellipsis, ) }, + subtitle = { + subtitle?.let { subtitle -> + Text( + modifier = Modifier + .offset(y = offsetY.dp) + .alpha(alpha), + text = subtitle.getString(), + color = contentColor, + maxLines = 2, + style = MaterialTheme.typography.titleMedium, + overflow = TextOverflow.Ellipsis, + ) + } + }, navigationIcon = { IconButton( modifier = Modifier.testTag(TestTags.Components.TopAppBar.NAVIGATE_UP), diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/extension/DateTimeExtension.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/extension/DateTimeExtension.kt index 6a05d8372..c391c8a08 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/extension/DateTimeExtension.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/extension/DateTimeExtension.kt @@ -34,3 +34,15 @@ fun LocalDate?.localizeMonthYear(useLong: Boolean = true): String? = this?.let { year, ) } + +@Composable +fun LocalDate?.localizeFull(useLong: Boolean = true): String? = this?.let { + stringResource( + UiString.core_ui_localized_date_full, + stringResource( + Month.from(month.number).run { if (useLong) long else short }, + ), + day, + year, + ) +} diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/provider/PersonParameterProvider.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/provider/PersonParameterProvider.kt index 702e6af9a..6ef79515c 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/provider/PersonParameterProvider.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/provider/PersonParameterProvider.kt @@ -13,12 +13,14 @@ class PersonParameterProvider : PreviewParameterProvider { name = "Brian Baumgartner", role = listOf( PersonRole.SeriesActor( - "Kevin Malone", + character = "Kevin Malone", totalEpisodes = 217, + creditId = "123456789", ), PersonRole.SeriesActor( - "Self", + character = "Self", totalEpisodes = 10, + creditId = "987654321", ), ), knownForDepartment = "Acting", diff --git a/detekt.yml b/detekt.yml index 7d5d5e9f4..2ba8a0b0e 100644 --- a/detekt.yml +++ b/detekt.yml @@ -109,7 +109,7 @@ complexity: ignoredLabels: [ ] LargeClass: active: true - excludes: [ '**Test.kt' ] + excludes: [ '**Test.kt', '**DatabaseImpl**' ] threshold: 600 LongMethod: active: true diff --git a/feature/credits/src/commonMain/kotlin/com.divinelink.feature.credits/provider/CreditsUiStateParameterProvider.kt b/feature/credits/src/commonMain/kotlin/com.divinelink.feature.credits/provider/CreditsUiStateParameterProvider.kt index 35d60d1a3..da58cec5e 100644 --- a/feature/credits/src/commonMain/kotlin/com.divinelink.feature.credits/provider/CreditsUiStateParameterProvider.kt +++ b/feature/credits/src/commonMain/kotlin/com.divinelink.feature.credits/provider/CreditsUiStateParameterProvider.kt @@ -162,9 +162,11 @@ private object CreditsUiStateParameters { role = listOf( PersonRole.SeriesActor( character = "Character 1", + creditId = "", ), PersonRole.SeriesActor( character = "Character 2", + creditId = "", ), ), ) @@ -178,6 +180,7 @@ private object CreditsUiStateParameters { PersonRole.SeriesActor( character = "", totalEpisodes = 10, + creditId = "", ), ), ) @@ -191,6 +194,7 @@ private object CreditsUiStateParameters { PersonRole.SeriesActor( character = "Character $it", totalEpisodes = it, + creditId = "", ) }, ) diff --git a/feature/details/src/commonMain/composeResources/values/strings.xml b/feature/details/src/commonMain/composeResources/values/strings.xml index ccaaf66db..967591296 100644 --- a/feature/details/src/commonMain/composeResources/values/strings.xml +++ b/feature/details/src/commonMain/composeResources/values/strings.xml @@ -84,11 +84,9 @@ Original language Budget Revenue - First air date Last air date Next episode air date Seasons - Aired episodes Country 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 2b6aed560..84e61e343 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 @@ -487,6 +487,16 @@ private fun SharedTransitionScope.MediaDetailsContent( modifier = Modifier.fillMaxSize(), title = uiState.mediaDetails.title, reviews = form.data as DetailsData.Seasons, + onClick = { seasonNumber -> + onNavigate( + Navigation.SeasonRoute( + showId = uiState.mediaDetails.id, + backdropPath = uiState.mediaDetails.backdropPath, + title = uiState.mediaDetails.title, + seasonNumber = seasonNumber, + ), + ) + }, ) } } diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/MovieInformationSection.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/MovieInformationSection.kt index dad6c3714..8a2588041 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/MovieInformationSection.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/MovieInformationSection.kt @@ -12,8 +12,10 @@ import androidx.compose.ui.Modifier import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.model.details.media.MediaDetailsInformation import com.divinelink.core.model.locale.Country +import com.divinelink.core.ui.SimpleInformationRow +import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.resources.core_ui_information import com.divinelink.feature.details.resources.Res -import com.divinelink.feature.details.resources.feature_details_information import com.divinelink.feature.details.resources.feature_details_information_budget import com.divinelink.feature.details.resources.feature_details_information_companies import com.divinelink.feature.details.resources.feature_details_information_countries @@ -33,7 +35,7 @@ fun MovieInformationSection(information: MediaDetailsInformation.Movie) { ) { Text( modifier = Modifier.padding(bottom = MaterialTheme.dimensions.keyline_8), - text = stringResource(Res.string.feature_details_information), + text = stringResource(UiString.core_ui_information), style = MaterialTheme.typography.titleMedium, ) @@ -75,29 +77,6 @@ fun MovieInformationSection(information: MediaDetailsInformation.Movie) { } } -@Composable -fun SimpleInformationRow( - title: String, - data: String, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), - ) { - Text( - modifier = Modifier.weight(0.35f), - text = title, - style = MaterialTheme.typography.bodyMedium, - ) - - Text( - modifier = Modifier.weight(0.65f), - text = data, - style = MaterialTheme.typography.bodyMedium, - ) - } -} - @Composable fun CountriesRow(countries: List) { Row( diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/TvInformationSection.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/TvInformationSection.kt index 28a2984b3..42f748ca3 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/TvInformationSection.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/TvInformationSection.kt @@ -11,10 +11,12 @@ import androidx.compose.ui.Modifier import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.model.details.TvStatus import com.divinelink.core.model.details.media.MediaDetailsInformation +import com.divinelink.core.ui.SimpleInformationRow +import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.resources.core_ui_aired_episodes +import com.divinelink.core.ui.resources.core_ui_first_air_date import com.divinelink.feature.details.resources.Res import com.divinelink.feature.details.resources.feature_details_information -import com.divinelink.feature.details.resources.feature_details_information_aired_episodes -import com.divinelink.feature.details.resources.feature_details_information_first_air_date import com.divinelink.feature.details.resources.feature_details_information_last_air_date import com.divinelink.feature.details.resources.feature_details_information_next_episode_air_date import com.divinelink.feature.details.resources.feature_details_information_original_language @@ -48,7 +50,7 @@ fun TvInformationSection(information: MediaDetailsInformation.TV) { } SimpleInformationRow( - title = stringResource(Res.string.feature_details_information_first_air_date), + title = stringResource(UiString.core_ui_first_air_date), data = information.firstAirDate, ) @@ -70,7 +72,7 @@ fun TvInformationSection(information: MediaDetailsInformation.TV) { ) SimpleInformationRow( - title = stringResource(Res.string.feature_details_information_aired_episodes), + title = stringResource(UiString.core_ui_aired_episodes), data = information.episodes.toString(), ) diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/seasons/SeasonsFormContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/seasons/SeasonsFormContent.kt index c40e9e377..491564060 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/seasons/SeasonsFormContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/forms/seasons/SeasonsFormContent.kt @@ -1,12 +1,14 @@ package com.divinelink.feature.details.media.ui.forms.seasons import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -14,6 +16,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -44,20 +47,18 @@ fun SeasonsFormContent( modifier: Modifier = Modifier, title: String, reviews: DetailsData.Seasons, + onClick: (seasonNumber: Int) -> Unit, ) { ScenePeekLazyColumn( modifier = modifier.testTag(TestTags.Details.Seasons.FORM), - contentPadding = PaddingValues( - top = MaterialTheme.dimensions.keyline_16, - start = MaterialTheme.dimensions.keyline_16, - end = MaterialTheme.dimensions.keyline_16, - ), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + contentPadding = PaddingValues(top = MaterialTheme.dimensions.keyline_16), ) { if (reviews.items.isEmpty()) { item { BlankSlate( - modifier = Modifier.testTag(TestTags.Details.Seasons.EMPTY), + modifier = Modifier + .padding(horizontal = MaterialTheme.dimensions.keyline_16) + .testTag(TestTags.Details.Seasons.EMPTY), uiState = BlankSlateState.Custom( title = UIText.ResourceText(Res.string.feature_details_no_seasons_available), description = UIText.ResourceText( @@ -70,8 +71,8 @@ fun SeasonsFormContent( } else { items(items = reviews.items) { item -> SeasonItem( - modifier = Modifier, season = item, + onClick = onClick, ) } @@ -86,8 +87,16 @@ fun SeasonsFormContent( fun SeasonItem( modifier: Modifier = Modifier, season: Season, + onClick: (seasonNumber: Int) -> Unit, ) { Row( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .clickable { onClick(season.seasonNumber) } + .padding( + horizontal = MaterialTheme.dimensions.keyline_16, + vertical = MaterialTheme.dimensions.keyline_8, + ), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), ) { MovieImage( @@ -96,7 +105,7 @@ fun SeasonItem( ) Column( - modifier = modifier.weight(5f), + modifier = Modifier.weight(5f), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), ) { Row( @@ -144,14 +153,17 @@ fun SeasonItemPreview() { ) { SeasonItem( season = SeasonFactory.season1(), + onClick = {}, ) SeasonItem( season = SeasonFactory.season2().copy(status = JellyseerrStatus.Media.AVAILABLE), + onClick = {}, ) SeasonItem( season = SeasonFactory.season3().copy(status = JellyseerrStatus.Request.PENDING), + onClick = {}, ) } } diff --git a/feature/season/build.gradle.kts b/feature/season/build.gradle.kts new file mode 100644 index 000000000..da3d0e939 --- /dev/null +++ b/feature/season/build.gradle.kts @@ -0,0 +1,23 @@ +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(libs.kotlinx.datetime) + } + } +} + +compose.resources { + publicResClass = false + packageOfResClass = "com.divinelink.feature.season.resources" + generateResClass = auto +} diff --git a/feature/season/src/commonMain/composeResources/values/strings.xml b/feature/season/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/feature/season/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt new file mode 100644 index 000000000..00fd131f8 --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt @@ -0,0 +1,5 @@ +package com.divinelink.feature.season + +sealed interface SeasonAction { + data class OnSelectTab(val index: Int) : SeasonAction +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt new file mode 100644 index 000000000..d14e7a4ed --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt @@ -0,0 +1,35 @@ +package com.divinelink.feature.season + +import com.divinelink.core.model.details.Season +import com.divinelink.core.model.details.season.SeasonForm +import com.divinelink.core.model.details.season.SeasonForms +import com.divinelink.core.model.tab.SeasonTab +import com.divinelink.core.model.tab.Tab +import com.divinelink.core.navigation.route.Navigation + +data class SeasonUiState( + val backdropPath: String?, + val title: String, + val season: Season?, + val tabs: List, + val selectedTab: Int, + val forms: SeasonForms, +) { + companion object { + fun initial(route: Navigation.SeasonRoute) = SeasonUiState( + backdropPath = route.backdropPath, + title = route.title, + season = null, + selectedTab = 0, + tabs = SeasonTab.entries, + forms = SeasonTab.entries.associateWith { tab -> + when (tab) { + SeasonTab.Episodes -> SeasonForm.Loading + SeasonTab.About -> SeasonForm.Loading + SeasonTab.Cast -> SeasonForm.Loading + SeasonTab.GuestStars -> SeasonForm.Loading + } + }, + ) + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt new file mode 100644 index 000000000..9b73119cc --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -0,0 +1,144 @@ +package com.divinelink.feature.season + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.divinelink.core.data.media.repository.MediaRepository +import com.divinelink.core.model.details.SeasonDetails +import com.divinelink.core.model.details.season.SeasonData +import com.divinelink.core.model.details.season.SeasonForm +import com.divinelink.core.model.details.season.SeasonInformation +import com.divinelink.core.model.tab.SeasonTab +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.network.Resource +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +class SeasonViewModel( + val repository: MediaRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val route: Navigation.SeasonRoute = Navigation.SeasonRoute( + backdropPath = savedStateHandle.get("backdropPath"), + title = savedStateHandle.get("title") ?: "", + showId = savedStateHandle.get("showId") ?: -1, + seasonNumber = savedStateHandle.get("seasonNumber") ?: -1, + ) + + private val _uiState: MutableStateFlow = MutableStateFlow( + SeasonUiState.initial(route), + ) + val uiState: StateFlow = _uiState + + init { + repository.fetchSeason( + showId = route.showId, + seasonNumber = route.seasonNumber, + ) + .distinctUntilChanged() + .onEach { result -> + result.fold( + onSuccess = { + _uiState.update { state -> + val aboutTab = SeasonTab.About + + val updatedForms = state.forms.toMutableMap().apply { + this[aboutTab] = SeasonForm.Content( + SeasonData.About( + overview = it.overview, + information = SeasonInformation( + totalEpisodes = it.episodeCount, + totalRuntime = null, + airedEpisodes = null, + lastAirDate = null, + firstAirDate = it.airDate, + ), + ), + ) + } + + state.copy( + season = it, + forms = updatedForms, + ) + } + }, + onFailure = { + Napier.d(it.message.toString()) + }, + ) + } + .launchIn(viewModelScope) + + repository.fetchSeasonDetails( + showId = route.showId, + season = route.seasonNumber, + ) + .distinctUntilChanged() + .catch { + Napier.d(it.message.toString()) + } + .onEach { result -> + when (result) { + is Resource.Error -> Napier.d(result.error.message.toString()) + is Resource.Loading -> result.data?.let { data -> handleSuccessResponse(data) } + is Resource.Success -> result.data?.let { data -> handleSuccessResponse(data) } + } + } + .launchIn(viewModelScope) + } + + fun onAction(action: SeasonAction) { + when (action) { + is SeasonAction.OnSelectTab -> handleSelectTab(action) + } + } + + private fun handleSelectTab(action: SeasonAction.OnSelectTab) { + _uiState.update { state -> + state.copy( + selectedTab = action.index, + ) + } + } + + private fun handleSuccessResponse(data: SeasonDetails) { + _uiState.update { state -> + val episodeTab = SeasonTab.Episodes + val aboutTab = SeasonTab.About + val castTab = SeasonTab.GuestStars + + val updatedForms = state.forms.toMutableMap().apply { + this[episodeTab] = SeasonForm.Content( + SeasonData.Episodes(data.episodes), + ) + this[aboutTab] = SeasonForm.Content( + SeasonData.About( + overview = data.overview, + information = SeasonInformation( + totalEpisodes = data.episodeCount, + totalRuntime = data.totalRuntime, + airedEpisodes = data.episodes.count { it.runtime != null }, + lastAirDate = data.episodes.findLast { it.runtime != null }?.airDate, + firstAirDate = data.airDate, + ), + ), + ) + this[castTab] = SeasonForm.Content( + SeasonData.GuestStars(cast = data.guestStars), + ) + } + + state.copy( + forms = updatedForms, + ) + } + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonContent.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonContent.kt new file mode 100644 index 000000000..b8729157b --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonContent.kt @@ -0,0 +1,202 @@ +package com.divinelink.feature.season.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.UIText +import com.divinelink.core.model.details.season.SeasonData +import com.divinelink.core.model.details.season.SeasonForm +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.Previews +import com.divinelink.core.ui.SharedTransitionScopeProvider +import com.divinelink.core.ui.UiString +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.JellyseerrStatusPill +import com.divinelink.core.ui.components.LoadingContent +import com.divinelink.core.ui.resources.core_ui_no_cast_available +import com.divinelink.core.ui.resources.core_ui_no_cast_available_description +import com.divinelink.core.ui.resources.core_ui_no_guest_stars_available +import com.divinelink.core.ui.resources.core_ui_no_guest_stars_available_description +import com.divinelink.core.ui.tab.ScenePeekTabs +import com.divinelink.feature.season.SeasonAction +import com.divinelink.feature.season.SeasonUiState +import com.divinelink.feature.season.ui.components.SeasonTitleDetails +import com.divinelink.feature.season.ui.forms.PeopleFormContent +import com.divinelink.feature.season.ui.forms.about.AboutFormContent +import com.divinelink.feature.season.ui.forms.episodes.EpisodesFormContent +import com.divinelink.feature.season.ui.provider.SeasonUiStateParameterProvider +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@Composable +fun SharedTransitionScope.SeasonContent( + visibilityScope: AnimatedVisibilityScope, + uiState: SeasonUiState, + onBackdropLoaded: () -> Unit, + toolbarProgress: (Float) -> Unit, + onNavigate: (Navigation) -> Unit, + action: (SeasonAction) -> Unit, +) { + if (uiState.season == null) return + val scope = rememberCoroutineScope() + + var selectedPage by rememberSaveable { mutableIntStateOf(uiState.selectedTab) } + val pagerState = rememberPagerState( + initialPage = selectedPage, + pageCount = { uiState.tabs.size }, + ) + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .distinctUntilChanged() + .collectLatest { page -> + selectedPage = page + action(SeasonAction.OnSelectTab(page)) + } + } + + DetailCollapsibleContent( + visibilityScope = visibilityScope, + backdropPath = uiState.backdropPath, + posterPath = uiState.season.posterPath, + toolbarProgress = toolbarProgress, + onBackdropLoaded = onBackdropLoaded, + onNavigateToMediaPoster = { onNavigate(Navigation.MediaPosterRoute(it)) }, + headerContent = { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + SeasonTitleDetails( + onNavigate = onNavigate, + title = uiState.title, + season = uiState.season, + ) + + uiState.season.status?.let { + JellyseerrStatusPill( + modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_8), + status = it, + ) + } + } + }, + content = { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + ScenePeekTabs( + tabs = uiState.tabs, + selectedIndex = selectedPage, + onClick = { scope.launch { pagerState.animateScrollToPage(it) } }, + ) + + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + state = pagerState, + ) { page -> + uiState.forms.values.elementAt(page).let { form -> + when (form) { + is SeasonForm.Content -> when (form.data) { + is SeasonData.Episodes -> EpisodesFormContent( + data = form.data as SeasonData.Episodes, + ) + is SeasonData.About -> AboutFormContent( + aboutData = form.data as SeasonData.About, + ) + is SeasonData.Cast -> PeopleFormContent( + cast = (form.data as SeasonData.Cast).cast, + onNavigate = onNavigate, + blankSlateState = BlankSlateState.Custom( + title = UIText.ResourceText(UiString.core_ui_no_cast_available), + description = UIText.ResourceText( + UiString.core_ui_no_cast_available_description, + uiState.season.name, + ), + ), + ) + is SeasonData.GuestStars -> PeopleFormContent( + modifier = Modifier.fillMaxSize(), + cast = (form.data as SeasonData.GuestStars).cast, + onNavigate = onNavigate, + blankSlateState = BlankSlateState.Custom( + title = UIText.ResourceText(UiString.core_ui_no_guest_stars_available), + description = UIText.ResourceText( + UiString.core_ui_no_guest_stars_available_description, + uiState.season.name, + ), + ), + ) + } + + SeasonForm.Error -> Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = MaterialTheme.dimensions.keyline_16) + .verticalScroll(rememberScrollState()), + ) { + BlankSlate(uiState = BlankSlateState.Contact) + } + + SeasonForm.Loading -> Box(modifier = Modifier.fillMaxSize()) { + LoadingContent( + modifier = Modifier + .padding(top = MaterialTheme.dimensions.keyline_16) + .align(Alignment.TopCenter) + .verticalScroll(rememberScrollState()), + ) + } + } + } + } + } + }, + ) +} + +@Composable +@Previews +fun SeasonContentPreview( + @PreviewParameter(SeasonUiStateParameterProvider::class) state: SeasonUiState, +) { + SharedTransitionScopeProvider { scope -> + scope.SeasonContent( + visibilityScope = this, + uiState = state, + onBackdropLoaded = {}, + toolbarProgress = {}, + onNavigate = {}, + action = {}, + ) + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonScreen.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonScreen.kt new file mode 100644 index 000000000..c52b7ad87 --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonScreen.kt @@ -0,0 +1,111 @@ +package com.divinelink.feature.season.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.season.SeasonViewModel +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnimatedVisibilityScope.SeasonScreen( + onNavigate: (Navigation) -> Unit, + viewModel: SeasonViewModel = 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.season?.name ?: ""), + contentColor = textColor, + subtitle = UIText.StringText(uiState.title), + progress = toolbarProgress, + onNavigateUp = { onNavigate(Navigation.Back) }, + topAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + }, + floatingActionButton = { + }, + content = { + Column { + Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + + SeasonContent( + visibilityScope = this@SeasonScreen, + uiState = uiState, + onBackdropLoaded = { onBackdropLoaded = true }, + toolbarProgress = { progress -> toolbarProgress = progress }, + action = viewModel::onAction, + onNavigate = onNavigate, + ) + } + }, + ) +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/components/SeasonTitleDetails.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/components/SeasonTitleDetails.kt new file mode 100644 index 000000000..3a9fa4e8e --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/components/SeasonTitleDetails.kt @@ -0,0 +1,45 @@ +package com.divinelink.feature.season.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.divinelink.core.commons.extensions.toLocalDate +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.details.Season +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.extension.localizeFull + +@Composable +fun SeasonTitleDetails( + modifier: Modifier = Modifier, + onNavigate: (Navigation) -> Unit, + title: String, + season: Season, +) { + Column(modifier = modifier) { + Text( + modifier = Modifier.clickable { onNavigate(Navigation.Back) }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + text = title, + ) + + Spacer(modifier = Modifier.height(MaterialTheme.dimensions.keyline_8)) + + Text( + style = MaterialTheme.typography.titleMedium, + text = season.name, + ) + + Text( + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + text = season.airDate.toLocalDate()?.localizeFull(useLong = true) ?: season.airDate, + ) + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/PeopleFormContent.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/PeopleFormContent.kt new file mode 100644 index 000000000..184ececcc --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/PeopleFormContent.kt @@ -0,0 +1,66 @@ +package com.divinelink.feature.season.ui.forms + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +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.details.Person +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.blankslate.BlankSlate +import com.divinelink.core.ui.blankslate.BlankSlateState +import com.divinelink.core.ui.credit.PersonItem + +@Composable +fun PeopleFormContent( + modifier: Modifier = Modifier, + cast: List, + onNavigate: (Navigation) -> Unit, + blankSlateState: BlankSlateState, +) { + ScenePeekLazyColumn( + modifier = modifier.testTag(TestTags.Details.Cast.FORM), + contentPadding = PaddingValues( + top = MaterialTheme.dimensions.keyline_16, + start = MaterialTheme.dimensions.keyline_16, + end = MaterialTheme.dimensions.keyline_16, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + if (cast.isEmpty()) { + item { + BlankSlate( + modifier = Modifier + .padding(top = MaterialTheme.dimensions.keyline_12) + .testTag(TestTags.Details.Cast.EMPTY), + uiState = blankSlateState, + ) + } + } else { + items( + items = cast, + key = { it.id }, + ) { person -> + PersonItem( + person = person, + onClick = { onNavigate(it.toPersonRoute()) }, + isObfuscated = false, + ) + } + + item { + Spacer(modifier = Modifier.height(LocalBottomNavigationPadding.current)) + } + } + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/about/AboutFormContent.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/about/AboutFormContent.kt new file mode 100644 index 000000000..2e5fb93df --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/about/AboutFormContent.kt @@ -0,0 +1,118 @@ +package com.divinelink.feature.season.ui.forms.about + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.divinelink.core.commons.extensions.toLocalDate +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.details.season.SeasonData +import com.divinelink.core.model.details.season.SeasonInformation +import com.divinelink.core.ui.SimpleInformationRow +import com.divinelink.core.ui.TestTags +import com.divinelink.core.ui.UiString +import com.divinelink.core.ui.extension.localizeFull +import com.divinelink.core.ui.resources.core_ui_aired_episodes +import com.divinelink.core.ui.resources.core_ui_episodes +import com.divinelink.core.ui.resources.core_ui_first_air_date +import com.divinelink.core.ui.resources.core_ui_information +import com.divinelink.core.ui.resources.core_ui_last_air_date +import com.divinelink.core.ui.resources.core_ui_total_runtime +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AboutFormContent( + modifier: Modifier = Modifier, + aboutData: SeasonData.About, +) { + ScenePeekLazyColumn( + modifier = modifier + .fillMaxSize() + .testTag(TestTags.Details.About.FORM), + contentPadding = PaddingValues( + top = MaterialTheme.dimensions.keyline_16, + start = MaterialTheme.dimensions.keyline_16, + end = MaterialTheme.dimensions.keyline_16, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + ) { + if (!aboutData.overview.isNullOrEmpty()) { + item { + Text( + text = aboutData.overview!!, + style = MaterialTheme.typography.bodyMedium, + ) + } + item { + HorizontalDivider() + } + } + + item { + SeasonAboutInformation(aboutData.information) + } + + item { + Spacer(modifier = Modifier.height(LocalBottomNavigationPadding.current)) + } + } +} + +@Composable +fun SeasonAboutInformation(info: SeasonInformation) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + ) { + Text( + modifier = Modifier.padding(bottom = MaterialTheme.dimensions.keyline_8), + text = stringResource(UiString.core_ui_information), + style = MaterialTheme.typography.titleMedium, + ) + + SimpleInformationRow( + title = stringResource(UiString.core_ui_episodes), + data = info.totalEpisodes.toString(), + ) + + info.airedEpisodes?.let { episodes -> + SimpleInformationRow( + title = stringResource(UiString.core_ui_aired_episodes), + data = episodes.toString(), + ) + } + + info.firstAirDate?.toLocalDate().localizeFull()?.let { airDate -> + SimpleInformationRow( + title = stringResource(UiString.core_ui_first_air_date), + data = airDate, + ) + } + + info.lastAirDate?.toLocalDate().localizeFull()?.let { airDate -> + SimpleInformationRow( + title = stringResource(UiString.core_ui_last_air_date), + data = airDate, + ) + } + + info.totalRuntime?.let { runtime -> + SimpleInformationRow( + title = stringResource(UiString.core_ui_total_runtime), + data = runtime, + ) + } + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt new file mode 100644 index 000000000..746f512db --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt @@ -0,0 +1,104 @@ +package com.divinelink.feature.season.ui.forms.episodes + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +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.draw.clip +import com.divinelink.core.commons.extensions.toLocalDate +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.details.Episode +import com.divinelink.core.model.details.season.SeasonData +import com.divinelink.core.ui.extension.localizeFull + +@Composable +fun EpisodesFormContent( + modifier: Modifier = Modifier, + data: SeasonData.Episodes, +) { + ScenePeekLazyColumn( + modifier = modifier.fillMaxSize(), + ) { + items( + items = data.episodes, + key = { it.id }, + ) { episode -> + EpisodeItem( + episode = episode, + onClick = { + // TODO Navigate to episode details + }, + ) + } + + item { + Spacer(modifier = Modifier.height(LocalBottomNavigationPadding.current)) + } + } +} + +@Composable +fun EpisodeItem( + episode: Episode, + onClick: (Episode) -> Unit, +) { + Column( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .clickable { onClick(episode) } + .padding(top = MaterialTheme.dimensions.keyline_16) + .padding(horizontal = MaterialTheme.dimensions.keyline_16), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = episode.number.toString(), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + Text( + text = episode.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + episode.airDate?.toLocalDate().localizeFull()?.let { airDate -> + Text( + text = buildString { + append(airDate) + + episode.runtime?.let { runtime -> + append(" • $runtime") + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + HorizontalDivider( + modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_16), + ) + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/navigation/SeasonNavigation.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/navigation/SeasonNavigation.kt new file mode 100644 index 000000000..ff83fa4ae --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/navigation/SeasonNavigation.kt @@ -0,0 +1,14 @@ +package com.divinelink.feature.season.ui.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.feature.season.ui.SeasonScreen + +fun NavGraphBuilder.seasonScreen(onNavigate: (Navigation) -> Unit) { + composable { + SeasonScreen( + onNavigate = onNavigate, + ) + } +} diff --git a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/provider/SeasonUiStateParameterProvider.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/provider/SeasonUiStateParameterProvider.kt new file mode 100644 index 000000000..312a5ab6b --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/provider/SeasonUiStateParameterProvider.kt @@ -0,0 +1,8 @@ +package com.divinelink.feature.season.ui.provider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.divinelink.feature.season.SeasonUiState + +class SeasonUiStateParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2fc2ab49b..5e4e8b7df 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include(":core:fixtures") include(":feature:add-to-account") include(":feature:credits") include(":feature:details") +include(":feature:season") include(":feature:discover") include(":feature:home") include(":feature:lists")