From 958b2ad4771faf9c4226e2766db023b85d5d11d4 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Wed, 4 Feb 2026 19:27:09 +0200 Subject: [PATCH 1/3] feat: implement episode details screen --- app/build.gradle.kts | 1 + .../scenepeek/di/NavigationModule.kt | 9 + .../scenepeek/di/ViewModelModule.kt | 2 + .../home/navigation/NavigationRouter.kt | 6 + .../episode/EpisodeContentScreenshots.kt | 9 + .../data/media/repository/MediaRepository.kt | 12 ++ .../media/repository/ProdMediaRepository.kt | 32 +++- .../core/database/media/dao/MediaDao.kt | 5 + .../core/database/media/dao/ProdMediaDao.kt | 16 ++ .../media/mapper/EpisodeEntityMapper.kt | 1 + .../core/database/season/EpisodeEntity.sq | 9 +- .../composeResources/values/strings.xml | 1 + .../divinelink/core/model/details/Episode.kt | 3 +- .../divinelink/core/model/tab/EpisodeTab.kt | 5 + .../mapper/details/EpisodeResponseMapper.kt | 1 + .../model/details/season/EpisodeResponse.kt | 2 +- .../navigation/route/NavigateToEpisode.kt | 7 + .../core/navigation/route/Navigation.kt | 11 ++ .../ui/CollapsibleHeaderContent.kt | 32 ++-- .../components/details/AirDateWithRuntime.kt | 29 +++ .../com/divinelink/core/ui/tab/EpisodeTabs.kt | 76 ++++++++ feature/episode/build.gradle.kts | 23 +++ .../composeResources/values/strings.xml | 2 + .../feature/episode/EpisodeAction.kt | 5 + .../feature/episode/EpisodeUiState.kt | 29 +++ .../feature/episode/EpisodeViewModel.kt | 88 +++++++++ .../feature/episode/ui/EpisodeContent.kt | 171 ++++++++++++++++++ .../feature/episode/ui/EpisodeScreen.kt | 109 +++++++++++ .../feature/episode/ui/EpisodeTitleDetails.kt | 72 ++++++++ .../ui/navigation/EpisodeNavigation.kt | 14 ++ .../EpisodeUiStateParameterProvider.kt | 8 + .../feature/season/ui/SeasonContent.kt | 3 + .../feature/season/ui/SeasonScreen.kt | 2 - .../ui/forms/episodes/EpisodesFormContent.kt | 43 +++-- settings.gradle.kts | 1 + 35 files changed, 796 insertions(+), 43 deletions(-) create mode 100644 app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/episode/EpisodeContentScreenshots.kt create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/EpisodeTab.kt create mode 100644 core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/NavigateToEpisode.kt create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/AirDateWithRuntime.kt create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/tab/EpisodeTabs.kt create mode 100644 feature/episode/build.gradle.kts create mode 100644 feature/episode/src/commonMain/composeResources/values/strings.xml create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/navigation/EpisodeNavigation.kt create mode 100644 feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/provider/EpisodeUiStateParameterProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3cf5e711..57ce65d42 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { implementation(projects.feature.credits) implementation(projects.feature.details) implementation(projects.feature.discover) + implementation(projects.feature.episode) implementation(projects.feature.home) implementation(projects.feature.lists) implementation(projects.feature.mediaLists) 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 b83d36593..1fe7df19d 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/NavigationModule.kt @@ -12,6 +12,7 @@ import com.divinelink.feature.details.navigation.detailsScreen import com.divinelink.feature.details.navigation.personScreen import com.divinelink.feature.details.navigation.posterScreen import com.divinelink.feature.discover.ui.navigation.discoverScreen +import com.divinelink.feature.episode.ui.navigation.episodeScreen import com.divinelink.feature.home.navigation.homeScreen import com.divinelink.feature.lists.create.ui.navigation.createListScreen import com.divinelink.feature.lists.create.ui.navigation.editListScreen @@ -297,6 +298,14 @@ val navigationModule = module { } } + single(named()) { + { navController, _ -> + episodeScreen( + 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 b1db68caf..c6445e436 100644 --- a/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt +++ b/app/src/commonMain/kotlin/com/divinelink/scenepeek/di/ViewModelModule.kt @@ -8,6 +8,7 @@ import com.divinelink.feature.details.media.ui.DetailsViewModel import com.divinelink.feature.details.person.ui.PersonViewModel import com.divinelink.feature.discover.DiscoverViewModel import com.divinelink.feature.discover.filters.SelectFilterViewModel +import com.divinelink.feature.episode.EpisodeViewModel import com.divinelink.feature.home.HomeViewModel import com.divinelink.feature.lists.create.CreateListViewModel import com.divinelink.feature.lists.create.backdrop.SelectBackdropViewModel @@ -57,6 +58,7 @@ val appViewModelModule = module { viewModelOf(::SelectFilterViewModel) viewModelOf(::MediaListsViewModel) viewModelOf(::SeasonViewModel) + viewModelOf(::EpisodeViewModel) // 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 b9dac390d..dc74c6307 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 @@ -8,6 +8,7 @@ import com.divinelink.core.navigation.route.navigateToCreateList import com.divinelink.core.navigation.route.navigateToDetails import com.divinelink.core.navigation.route.navigateToDiscover import com.divinelink.core.navigation.route.navigateToEditList +import com.divinelink.core.navigation.route.navigateToEpisode import com.divinelink.core.navigation.route.navigateToListDetails import com.divinelink.core.navigation.route.navigateToLists import com.divinelink.core.navigation.route.navigateToMediaLists @@ -34,6 +35,10 @@ import com.divinelink.feature.settings.navigation.settings.navigateToSettings fun NavController.findNavigation(route: Navigation) { when (route) { Navigation.Back -> navigateUp() + Navigation.TwiceBack -> { + navigateUp() + navigateUp() + } Navigation.AboutSettingsRoute -> navigateToAboutSettings() Navigation.AccountSettingsRoute -> navigateToAccountSettings() Navigation.DetailsPreferencesSettingsRoute -> navigateToDetailsPreferenceSettings() @@ -64,6 +69,7 @@ fun NavController.findNavigation(route: Navigation) { is Navigation.MediaPosterRoute -> navigateToPoster(route) is Navigation.MediaListsRoute -> navigateToMediaLists(route) is Navigation.SeasonRoute -> navigateToSeason(route) + is Navigation.EpisodeRoute -> navigateToEpisode(route) // This is from top level navigation Navigation.HomeRoute -> Unit diff --git a/app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/episode/EpisodeContentScreenshots.kt b/app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/episode/EpisodeContentScreenshots.kt new file mode 100644 index 000000000..f804f802b --- /dev/null +++ b/app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/episode/EpisodeContentScreenshots.kt @@ -0,0 +1,9 @@ +package com.divinelink.scenepeek.feature.episode + +@Previews +@Composable +fun EpisodeContentScreenshots( + @PreviewParameter(EpisodeUiStateParameterProvider::class) uiState: EpisodeUiState, +) { + EpisodeContentPreview(uiState) +} 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 e04e14785..4a34f1e06 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 @@ -2,6 +2,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.Episode import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.discover.DiscoverFilter @@ -98,4 +99,15 @@ interface MediaRepository { showId: Int, season: Int, ): Flow> + + fun fetchEpisode( + showId: Int, + season: Int, + number: Int, + ): Flow> + + fun getSeasonEpisodesNumber( + showId: Int, + season: Int, + ): Result> } 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 9e3827b84..13f50afde 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 @@ -7,6 +7,7 @@ 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.Episode import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.discover.DiscoverFilter @@ -274,9 +275,8 @@ class ProdMediaRepository( } } - override fun fetchTvSeasons(id: Int): Flow>> = dao.fetchSeasons(id).map { - Result.success(it) - } + override fun fetchTvSeasons(id: Int): Flow>> = dao.fetchSeasons(id) + .map { runCatching { it } } override fun fetchSeason( showId: Int, @@ -341,4 +341,30 @@ class ProdMediaRepository( }, shouldFetch = { true }, ) + + override fun getSeasonEpisodesNumber( + showId: Int, + season: Int, + ): Result> = runCatching { + dao.fetchSeasonEpisodesCount( + showId = showId, + season = season, + ) + } + + override fun fetchEpisode( + showId: Int, + season: Int, + number: Int, + ): Flow> = flow { + emit( + runCatching { + dao.fetchEpisode( + showId = showId, + episodeNumber = number, + seasonNumber = season, + ) + }, + ) + } } 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 ec1e70f19..6e9ac0222 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 @@ -96,4 +96,9 @@ interface MediaDao { season: Int, showId: Int, ): Flow + + fun fetchSeasonEpisodesCount( + season: Int, + showId: Int, + ): List } 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 978f42989..7563dc9be 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 @@ -311,6 +311,7 @@ class ProdMediaDao( airDate = episode.airDate, stillPath = episode.stillPath, voteAverage = episode.voteAverage?.toDouble() ?: 0.0, + voteCount = episode.voteCount?.toLong() ?: 0, ), ) } @@ -387,4 +388,19 @@ class ProdMediaDao( .asFlow() .mapToOneOrNull(dispatcher.io) } + + override fun fetchSeasonEpisodesCount( + season: Int, + showId: Int, + ): List = database + .transactionWithResult { + database + .episodeEntityQueries + .countSeasonEpisodes( + showId = showId.toLong(), + seasonNumber = season.toLong(), + ) + .executeAsList() + .map { it.toInt() } + } } 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 index 46143d688..c088183c7 100644 --- 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 @@ -14,6 +14,7 @@ fun EpisodeEntity.map() = Episode( showId = showId.toInt(), stillPath = stillPath, voteAverage = voteAverage.toString(), + voteCount = voteCount.toInt(), crew = emptyList(), guestStars = emptyList(), ) 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 index ad16148cb..4ad07a80b 100644 --- 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 @@ -1,7 +1,7 @@ CREATE TABLE EpisodeEntity ( id INTEGER NOT NULL, showId INTEGER NOT NULL, - overview TEXT NOT NULL, + overview TEXT, name TEXT NOT NULL, runtime TEXT, episodeNumber INTEGER NOT NULL, @@ -9,6 +9,7 @@ CREATE TABLE EpisodeEntity ( airDate TEXT, stillPath TEXT, voteAverage REAL NOT NULL, + voteCount INTEGER NOT NULL, UNIQUE(id, seasonNumber, showId) ); @@ -23,6 +24,7 @@ INSERT OR REPLACE INTO EpisodeEntity ( seasonNumber, airDate, stillPath, + voteCount, voteAverage ) VALUES ?; @@ -40,3 +42,8 @@ fetchEpisode: SELECT * FROM EpisodeEntity WHERE showId = ? AND seasonNumber = ? AND episodeNumber = ? LIMIT 1; + +countSeasonEpisodes: +SELECT episodeNumber +FROM EpisodeEntity +WHERE showId = ? AND seasonNumber = ?; \ 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 34372636d..9f7361c5d 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 + E%1$s Episodes About Seasons 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 index e5c67dab5..22848b438 100644 --- 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 @@ -7,13 +7,14 @@ data class Episode( val id: Int, val name: String, val airDate: String?, - val overview: String, + val overview: String?, val runtime: String?, val number: Int, val seasonNumber: Int, val showId: Int, val stillPath: String?, val voteAverage: String?, + val voteCount: Int?, val crew: List, val guestStars: List, ) diff --git a/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/EpisodeTab.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/EpisodeTab.kt new file mode 100644 index 000000000..b9a564f18 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/EpisodeTab.kt @@ -0,0 +1,5 @@ +package com.divinelink.core.model.tab + +data class EpisodeTab( + val number: Int, +) 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 index 99aad8dc5..75164417e 100644 --- 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 @@ -14,6 +14,7 @@ fun EpisodeResponse.map() = Episode( seasonNumber = seasonNumber, showId = showId, stillPath = stillPath, + voteCount = voteCount, voteAverage = voteAverage, number = episodeNumber, crew = crew.map(), 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 index 5642c1ace..28dbff786 100644 --- 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 @@ -18,7 +18,7 @@ data class EpisodeResponse( @SerialName("show_id") val showId: Int, @SerialName("still_path") val stillPath: String?, @SerialName("vote_average") val voteAverage: String?, - @SerialName("vote_count") val voteCount: String, + @SerialName("vote_count") val voteCount: Int?, val crew: List, @SerialName("guest_stars") val guestStars: List, ) diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/NavigateToEpisode.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/NavigateToEpisode.kt new file mode 100644 index 000000000..e579bae88 --- /dev/null +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/NavigateToEpisode.kt @@ -0,0 +1,7 @@ +package com.divinelink.core.navigation.route + +import androidx.navigation.NavController + +fun NavController.navigateToEpisode(route: Navigation.EpisodeRoute) = navigate( + route = route, +) diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/Navigation.kt index f4397e942..add8b985b 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 @@ -8,6 +8,7 @@ import kotlinx.serialization.Serializable sealed interface Navigation { @Serializable data object Back : Navigation + data object TwiceBack : Navigation @Serializable data object HomeRoute : Navigation @@ -75,6 +76,16 @@ sealed interface Navigation { val title: String, ) : Navigation + @Serializable + data class EpisodeRoute( + val showId: Int, + val showTitle: String, + val seasonTitle: String, + val seasonNumber: Int, + val episodeIndex: Int, +// val availableEpisodes: List, + ) : Navigation + @Serializable data object ProfileRoute : Navigation diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt index 86b617c68..c66e43d92 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt @@ -71,21 +71,23 @@ fun SharedTransitionScope.CollapsibleHeaderContent( 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) }, - ) + if (posterPath != null) { + 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/components/details/AirDateWithRuntime.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/AirDateWithRuntime.kt new file mode 100644 index 000000000..ddce83c7c --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/AirDateWithRuntime.kt @@ -0,0 +1,29 @@ +package com.divinelink.core.ui.components.details + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import com.divinelink.core.commons.extensions.toLocalDate +import com.divinelink.core.ui.extension.localizeFull + +@Composable +fun AirDateWithRuntime( + airDate: String?, + runtime: String?, + style: TextStyle, +) { + airDate?.toLocalDate().localizeFull(useLong = true)?.let { airDate -> + Text( + text = buildString { + append(airDate) + + runtime?.let { runtime -> + append(" • $runtime") + } + }, + style = style, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/tab/EpisodeTabs.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/tab/EpisodeTabs.kt new file mode 100644 index 000000000..87228d444 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/tab/EpisodeTabs.kt @@ -0,0 +1,76 @@ +package com.divinelink.core.ui.tab + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +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.designsystem.theme.dimensions +import com.divinelink.core.model.resources.Res +import com.divinelink.core.model.resources.core_model_tab_episode +import com.divinelink.core.model.tab.EpisodeTab +import com.divinelink.core.ui.Previews +import com.divinelink.core.ui.TestTags +import com.divinelink.core.ui.composition.PreviewLocalProvider +import com.divinelink.core.ui.extension.format +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EpisodeTabs( + modifier: Modifier = Modifier, + tabs: List, + selectedIndex: Int, + onClick: (Int) -> Unit, +) { + Surface(modifier = Modifier.fillMaxWidth()) { + PrimaryScrollableTabRow( + edgePadding = MaterialTheme.dimensions.keyline_0, + modifier = modifier.fillMaxWidth(), + selectedTabIndex = (selectedIndex).coerceIn(0, tabs.size), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + minTabWidth = MaterialTheme.dimensions.keyline_72, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + modifier = Modifier.testTag(TestTags.Tabs.TAB_ITEM.format(tab.number)), + text = { + Text( + text = stringResource( + Res.string.core_model_tab_episode, + tab.number.toString().padStart(2, '0'), + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = MaterialTheme.typography.titleMedium.fontWeight, + color = if (index == selectedIndex) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + selected = index == selectedIndex, + onClick = { onClick(index) }, + ) + } + } + } +} + +@Composable +@Previews +fun EpisodeTabsPreview() { + PreviewLocalProvider { + EpisodeTabs( + tabs = (1..10).map { EpisodeTab(it) }, + selectedIndex = 0, + onClick = {}, + ) + } +} diff --git a/feature/episode/build.gradle.kts b/feature/episode/build.gradle.kts new file mode 100644 index 000000000..e08290e81 --- /dev/null +++ b/feature/episode/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.episode.resources" + generateResClass = auto +} diff --git a/feature/episode/src/commonMain/composeResources/values/strings.xml b/feature/episode/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/feature/episode/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt new file mode 100644 index 000000000..028d2a022 --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeAction.kt @@ -0,0 +1,5 @@ +package com.divinelink.feature.episode + +sealed interface EpisodeAction { + data class OnSelectEpisode(val index: Int) : EpisodeAction +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt new file mode 100644 index 000000000..9ec9f18ee --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeUiState.kt @@ -0,0 +1,29 @@ +package com.divinelink.feature.episode + +import com.divinelink.core.model.details.Episode +import com.divinelink.core.model.tab.EpisodeTab +import com.divinelink.core.navigation.route.Navigation + +data class EpisodeUiState( + val showId: Int, + val showTitle: String, + val seasonTitle: String, + val seasonNumber: Int, + val selectedIndex: Int, + val episodes: Map, + val tabs: List, +) { + val episode by lazy { episodes[selectedIndex] } + + companion object { + fun initial(route: Navigation.EpisodeRoute) = EpisodeUiState( + showId = route.showId, + showTitle = route.showTitle, + seasonTitle = route.seasonTitle, + seasonNumber = route.seasonNumber, + selectedIndex = route.episodeIndex, + episodes = emptyMap(), + tabs = emptyList(), + ) + } +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt new file mode 100644 index 000000000..ee3870755 --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/EpisodeViewModel.kt @@ -0,0 +1,88 @@ +package com.divinelink.feature.episode + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.divinelink.core.commons.data +import com.divinelink.core.data.media.repository.MediaRepository +import com.divinelink.core.model.tab.EpisodeTab +import com.divinelink.core.navigation.route.Navigation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +class EpisodeViewModel( + private val repository: MediaRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val route = Navigation.EpisodeRoute( + showId = savedStateHandle.get("showId") ?: -1, + showTitle = savedStateHandle.get("showTitle") ?: "", + seasonTitle = savedStateHandle.get("seasonTitle") ?: "", + seasonNumber = savedStateHandle.get("seasonNumber") ?: -1, + episodeIndex = savedStateHandle.get("episodeIndex") ?: -1, + ) + + private val _uiState: MutableStateFlow = MutableStateFlow( + EpisodeUiState.initial(route), + ) + val uiState: StateFlow = _uiState + + init { + repository + .getSeasonEpisodesNumber( + season = route.seasonNumber, + showId = route.showId, + ).fold( + onSuccess = { episodes -> + _uiState.update { state -> + state.copy( + tabs = episodes.map { episodeNumber -> EpisodeTab(episodeNumber) }, + ) + } + + fetchEpisode() + }, + onFailure = { + }, + ) + } + + private fun fetchEpisode() { + // TODO add check if episode does not exist to fetch it from DB + repository.fetchEpisode( + showId = uiState.value.showId, + season = uiState.value.seasonNumber, + number = uiState.value.tabs[uiState.value.selectedIndex].number, + ) + .distinctUntilChanged() + .onEach { + _uiState.update { uiState -> + val episode = it.data + + uiState.copy( + episodes = uiState.episodes.plus(uiState.selectedIndex to episode), + ) + } + } + .launchIn(viewModelScope) + } + + fun onAction(action: EpisodeAction) { + when (action) { + is EpisodeAction.OnSelectEpisode -> handleOnSelectEpisode(action) + } + } + + private fun handleOnSelectEpisode(action: EpisodeAction.OnSelectEpisode) { + _uiState.update { uiState -> + uiState.copy(selectedIndex = action.index) + } + + fetchEpisode() + } +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt new file mode 100644 index 000000000..ee3edfc85 --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt @@ -0,0 +1,171 @@ +package com.divinelink.feature.episode.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.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +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.component.ScenePeekLazyColumn +import com.divinelink.core.designsystem.theme.LocalBottomNavigationPadding +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.Previews +import com.divinelink.core.ui.SharedTransitionScopeProvider +import com.divinelink.core.ui.collapsingheader.ui.DetailCollapsibleContent +import com.divinelink.core.ui.components.LoadingContent +import com.divinelink.core.ui.composition.PreviewLocalProvider +import com.divinelink.core.ui.tab.EpisodeTabs +import com.divinelink.feature.episode.EpisodeAction +import com.divinelink.feature.episode.EpisodeUiState +import com.divinelink.feature.episode.ui.provider.EpisodeUiStateParameterProvider +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@OptIn(FlowPreview::class) +@Composable +fun SharedTransitionScope.EpisodeContent( + visibilityScope: AnimatedVisibilityScope, + uiState: EpisodeUiState, + onBackdropLoaded: () -> Unit, + toolbarProgress: (Float) -> Unit, + onNavigate: (Navigation) -> Unit, + action: (EpisodeAction) -> Unit, +) { + val episode = uiState.episode ?: return + + val scope = rememberCoroutineScope() + + val pagerState = rememberPagerState( + initialPage = uiState.selectedIndex, + pageCount = { uiState.tabs.size }, + ) + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .debounce { 100 } + .distinctUntilChanged() + .collectLatest { page -> + action(EpisodeAction.OnSelectEpisode(page)) + } + } + + DetailCollapsibleContent( + visibilityScope = visibilityScope, + backdropPath = episode.stillPath, + posterPath = null, + toolbarProgress = toolbarProgress, + onBackdropLoaded = onBackdropLoaded, + onNavigateToMediaPoster = { onNavigate(Navigation.MediaPosterRoute(it)) }, + headerContent = { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + EpisodeTitleDetails( + onNavigate = onNavigate, + title = uiState.showTitle, + season = uiState.seasonTitle, + episode = episode, + ) + } + }, + content = { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + EpisodeTabs( + tabs = uiState.tabs, + selectedIndex = uiState.selectedIndex, + onClick = { scope.launch { pagerState.animateScrollToPage(it) } }, + ) + + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + state = pagerState, + ) { page -> + ScenePeekLazyColumn( + modifier = Modifier + .fillMaxSize(), + contentPadding = PaddingValues( + top = MaterialTheme.dimensions.keyline_16, + start = MaterialTheme.dimensions.keyline_16, + end = MaterialTheme.dimensions.keyline_16, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), + ) { + val episode = uiState.episodes[page] + + if (episode == null) { + item { + Box(modifier = Modifier.fillMaxSize()) { + LoadingContent( + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + } else { + if (!episode.overview.isNullOrEmpty()) { + item { + Text( + text = episode.overview ?: "", + style = MaterialTheme.typography.bodyMedium, + ) + } + item { + HorizontalDivider() + } + } + } + + item { + Spacer(modifier = Modifier.height(LocalBottomNavigationPadding.current)) + } + } + } + } + }, + ) +} + +@Composable +@Previews +fun EpisodeContentPreview( + @PreviewParameter(EpisodeUiStateParameterProvider::class) state: EpisodeUiState, +) { + PreviewLocalProvider { + SharedTransitionScopeProvider { scope -> + scope.EpisodeContent( + visibilityScope = this, + uiState = state, + onBackdropLoaded = {}, + toolbarProgress = {}, + onNavigate = {}, + action = {}, + ) + } + } +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt new file mode 100644 index 000000000..13356168f --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt @@ -0,0 +1,109 @@ +package com.divinelink.feature.episode.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.episode.EpisodeViewModel +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnimatedVisibilityScope.EpisodeScreen( + onNavigate: (Navigation) -> Unit, + viewModel: EpisodeViewModel = 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.episode?.name ?: ""), + subtitle = UIText.StringText(uiState.showTitle), + contentColor = textColor, + progress = toolbarProgress, + onNavigateUp = { onNavigate(Navigation.Back) }, + topAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent, + ), + ) + }, + content = { + Column { + Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + + EpisodeContent( + visibilityScope = this@EpisodeScreen, + uiState = uiState, + onBackdropLoaded = { onBackdropLoaded = true }, + toolbarProgress = { progress -> toolbarProgress = progress }, + action = viewModel::onAction, + onNavigate = onNavigate, + ) + } + }, + ) +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt new file mode 100644 index 000000000..908c839ee --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeTitleDetails.kt @@ -0,0 +1,72 @@ +package com.divinelink.feature.episode.ui + +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.details.Episode +import com.divinelink.core.model.details.rating.RatingDetails +import com.divinelink.core.model.details.rating.RatingSource +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.components.details.AirDateWithRuntime +import com.divinelink.core.ui.rating.MediaRatingItem + +@Composable +fun EpisodeTitleDetails( + modifier: Modifier = Modifier, + onNavigate: (Navigation) -> Unit, + title: String, + season: String, + episode: Episode, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + ) { + Row { + Text( + modifier = Modifier.clickable { onNavigate(Navigation.TwiceBack) }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + text = title, + ) + + Text( + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + text = " / ", + ) + + Text( + modifier = Modifier.clickable { onNavigate(Navigation.Back) }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleSmall, + text = season, + ) + } + + Text( + style = MaterialTheme.typography.titleMedium, + text = episode.name, + ) + + AirDateWithRuntime( + airDate = episode.airDate, + runtime = episode.runtime, + style = MaterialTheme.typography.titleSmall, + ) + + MediaRatingItem( + ratingDetails = RatingDetails.Score( + voteAverage = episode.voteAverage?.toDouble() ?: 0.0, + voteCount = episode.voteCount ?: 0, + ), + source = RatingSource.TMDB, + ) + } +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/navigation/EpisodeNavigation.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/navigation/EpisodeNavigation.kt new file mode 100644 index 000000000..4b9f2b403 --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/navigation/EpisodeNavigation.kt @@ -0,0 +1,14 @@ +package com.divinelink.feature.episode.ui.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.feature.episode.ui.EpisodeScreen + +fun NavGraphBuilder.episodeScreen(onNavigate: (Navigation) -> Unit) { + composable { + EpisodeScreen( + onNavigate = onNavigate, + ) + } +} diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/provider/EpisodeUiStateParameterProvider.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/provider/EpisodeUiStateParameterProvider.kt new file mode 100644 index 000000000..2ef8c4b24 --- /dev/null +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/provider/EpisodeUiStateParameterProvider.kt @@ -0,0 +1,8 @@ +package com.divinelink.feature.episode.ui.provider + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.divinelink.feature.episode.EpisodeUiState + +class EpisodeUiStateParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf() +} 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 index b8729157b..ab427e9f7 100644 --- 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 @@ -130,6 +130,9 @@ fun SharedTransitionScope.SeasonContent( is SeasonForm.Content -> when (form.data) { is SeasonData.Episodes -> EpisodesFormContent( data = form.data as SeasonData.Episodes, + showTitle = uiState.title, + seasonTitle = uiState.season.name, + onNavigate = onNavigate, ) is SeasonData.About -> AboutFormContent( aboutData = form.data as SeasonData.About, 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 index c52b7ad87..261ef1639 100644 --- 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 @@ -91,8 +91,6 @@ fun AnimatedVisibilityScope.SeasonScreen( ), ) }, - floatingActionButton = { - }, content = { Column { Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) 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 index 746f512db..330352c3b 100644 --- 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 @@ -8,7 +8,7 @@ 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.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -16,30 +16,41 @@ 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 +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.components.details.AirDateWithRuntime @Composable fun EpisodesFormContent( modifier: Modifier = Modifier, + showTitle: String, + seasonTitle: String, data: SeasonData.Episodes, + onNavigate: (Navigation.EpisodeRoute) -> Unit, ) { ScenePeekLazyColumn( modifier = modifier.fillMaxSize(), ) { - items( + itemsIndexed( items = data.episodes, - key = { it.id }, - ) { episode -> + key = { _, episode -> episode.id }, + ) { index, episode -> EpisodeItem( episode = episode, onClick = { - // TODO Navigate to episode details + onNavigate( + Navigation.EpisodeRoute( + showId = episode.showId, + showTitle = showTitle, + seasonTitle = seasonTitle, + seasonNumber = episode.seasonNumber, + episodeIndex = index, + ), + ) }, ) } @@ -81,19 +92,11 @@ fun EpisodeItem( 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, - ) - } + AirDateWithRuntime( + airDate = episode.airDate, + runtime = episode.runtime, + style = MaterialTheme.typography.bodySmall, + ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e4e8b7df..2f8a7db94 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,7 @@ include(":feature:credits") include(":feature:details") include(":feature:season") include(":feature:discover") +include(":feature:episode") include(":feature:home") include(":feature:lists") include(":feature:media-lists") From 89546546cf8200f84c14a1cd84de7f3bb2985c9b Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Wed, 4 Feb 2026 21:34:31 +0200 Subject: [PATCH 2/3] feat: improve edge to edge backdrop position --- .../ui/CollapsibleHeaderContent.kt | 5 +++++ .../core/ui/components/details/BackdropImage.kt | 16 ---------------- .../feature/details/media/ui/DetailsContent.kt | 4 +++- .../ui/components/CollapsibleDetailsContent.kt | 5 +++++ .../feature/episode/ui/EpisodeScreen.kt | 4 +++- .../lists/details/ui/ListScrollableContent.kt | 1 - .../divinelink/feature/season/ui/SeasonScreen.kt | 4 +++- 7 files changed, 19 insertions(+), 20 deletions(-) diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt index c66e43d92..cac5d4846 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt @@ -6,6 +6,7 @@ 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.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -66,6 +67,10 @@ fun SharedTransitionScope.CollapsibleHeaderContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), ) { + Spacer( + modifier = Modifier.height(MaterialTheme.dimensions.keyline_72), + ) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/BackdropImage.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/BackdropImage.kt index 3db9d8370..a2b5b93bb 100644 --- a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/BackdropImage.kt +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/components/details/BackdropImage.kt @@ -1,11 +1,6 @@ package com.divinelink.core.ui.components.details -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.statusBars -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent @@ -19,10 +14,8 @@ import coil3.compose.AsyncImage import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade -import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.ui.UiString import com.divinelink.core.ui.coil.platformContext -import com.divinelink.core.ui.conditional import com.divinelink.core.ui.rememberConstants import com.divinelink.core.ui.resources.core_ui_backdrop_image_placeholder import org.jetbrains.compose.resources.stringResource @@ -33,20 +26,11 @@ fun BackdropImage( modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.FillWidth, onBackdropLoaded: () -> Unit, - applyOffset: Boolean = true, ) { val constants = rememberConstants() - val backdropOffset = WindowInsets.statusBars.asPaddingValues() - .calculateTopPadding() + MaterialTheme.dimensions.keyline_64 AsyncImage( modifier = modifier - .conditional( - condition = applyOffset, - ifTrue = { - offset(y = -backdropOffset) - }, - ) .bottomFadeOut() .fillMaxWidth(), model = ImageRequest.Builder(platformContext()) 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 84e61e343..8d0d4c3e0 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 @@ -284,7 +284,9 @@ fun DetailsContent( }, content = { paddingValues -> Column { - Spacer(modifier = Modifier.padding(top = paddingValues.calculateTopPadding())) + if (viewState.mediaDetails?.backdropPath?.isBlank() == true) { + Spacer(modifier = Modifier.padding(top = paddingValues.calculateTopPadding())) + } when (viewState.mediaDetails) { is Movie, is TV -> MediaDetailsContent( diff --git a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt index ee72ef1b1..a9c72cd4e 100644 --- a/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt +++ b/feature/details/src/commonMain/kotlin/com/divinelink/feature/details/media/ui/components/CollapsibleDetailsContent.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.SharedTransitionScope 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -85,6 +86,10 @@ fun SharedTransitionScope.CollapsibleDetailsContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), ) { + Spacer( + modifier = Modifier.height(MaterialTheme.dimensions.keyline_64), + ) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt index 13356168f..dfb7cd94f 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeScreen.kt @@ -93,7 +93,9 @@ fun AnimatedVisibilityScope.EpisodeScreen( }, content = { Column { - Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + if (uiState.episode?.stillPath?.isBlank() == true) { + Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + } EpisodeContent( visibilityScope = this@EpisodeScreen, diff --git a/feature/lists/src/commonMain/kotlin/com/divinelink/feature/lists/details/ui/ListScrollableContent.kt b/feature/lists/src/commonMain/kotlin/com/divinelink/feature/lists/details/ui/ListScrollableContent.kt index 18d3aa07f..9046099a7 100644 --- a/feature/lists/src/commonMain/kotlin/com/divinelink/feature/lists/details/ui/ListScrollableContent.kt +++ b/feature/lists/src/commonMain/kotlin/com/divinelink/feature/lists/details/ui/ListScrollableContent.kt @@ -187,7 +187,6 @@ fun ListScrollableContent( }, path = state.details.backdropPath, onBackdropLoaded = onBackdropLoaded, - applyOffset = false, ) } 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 index 261ef1639..c568e418a 100644 --- 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 @@ -93,7 +93,9 @@ fun AnimatedVisibilityScope.SeasonScreen( }, content = { Column { - Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + if (uiState.backdropPath?.isBlank() == true) { + Spacer(modifier = Modifier.padding(top = it.calculateTopPadding())) + } SeasonContent( visibilityScope = this@SeasonScreen, From 1d0a5a4a96242c209ce4bd5d2714b65f42772535 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Fri, 6 Feb 2026 20:09:51 +0200 Subject: [PATCH 3/3] feat: update episode guest schema and add episode guests --- .../media/repository/ProdMediaRepository.kt | 60 ++++++---- .../core/database/person/PersonDao.kt | 6 + .../core/database/person/ProdPersonDao.kt | 50 +++++++- .../database/season/EpisodeGuestStarEntity.sq | 30 +++++ .../season/SeasonGuestStarRoleEntity.sq | 29 ----- .../commonMain/sqldelight/migrations/8.sqm | 5 +- .../composeResources/values/strings.xml | 2 + .../core/ui/credit/SmallPersonItem.kt | 113 ++++++++++++++++++ .../feature/details/DetailsScreenTest.kt | 4 +- .../feature/episode/ui/EpisodeContent.kt | 66 ++++++++-- 10 files changed, 291 insertions(+), 74 deletions(-) create mode 100644 core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeGuestStarEntity.sq delete mode 100644 core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonGuestStarRoleEntity.sq create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/credit/SmallPersonItem.kt 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 13f50afde..3964482ac 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 @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext class ProdMediaRepository( private val remote: MediaService, @@ -320,23 +321,25 @@ class ProdMediaRepository( ).map { it.map() } }, saveFetchResult = { result -> - val data = result.data + withContext(dispatcher.io) { + val data = result.data - dao.insertSeasonDetails( - seasonDetails = data, - showId = showId, - seasonNumber = season, - ) - - dao.insertEpisodes(data.episodes) - - data.episodes.forEach { episode -> - personDao.insertGuestStars( - season = season, + dao.insertSeasonDetails( + seasonDetails = data, showId = showId, - episode = episode.number, - guestStars = data.guestStars, + seasonNumber = season, ) + + dao.insertEpisodes(data.episodes) + + data.episodes.forEach { episode -> + personDao.insertGuestStars( + season = season, + showId = showId, + episode = episode.number, + guestStars = episode.guestStars, + ) + } } }, shouldFetch = { true }, @@ -356,15 +359,24 @@ class ProdMediaRepository( showId: Int, season: Int, number: Int, - ): Flow> = flow { - emit( - runCatching { - dao.fetchEpisode( - showId = showId, - episodeNumber = number, - seasonNumber = season, - ) - }, - ) + ): Flow> = combine( + personDao.fetchEpisodeGuestStars( + showId = showId, + episode = number, + season = season, + ), + flowOf( + dao.fetchEpisode( + showId = showId, + episodeNumber = number, + seasonNumber = season, + ), + ), + ) { guestStars, episode -> + runCatching { + episode.copy( + 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 f7e809163..31d6f795b 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 @@ -49,4 +49,10 @@ interface PersonDao { showId: Int, season: Int, ): Flow> + + fun fetchEpisodeGuestStars( + showId: Int, + season: Int, + episode: 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 cc2b06458..80c5baf07 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 @@ -155,6 +155,7 @@ class ProdPersonDao( val insertedPersonIds = mutableSetOf() val insertedCreditIds = mutableSetOf() + // TODO Fix guest star insertion, only adds guest star to the last episode of the season. guestStars.forEach { person -> if (person.id !in insertedPersonIds) { database.personEntityQueries.insertPerson( @@ -182,12 +183,12 @@ class ProdPersonDao( ), ) - database.seasonGuestStarRoleEntityQueries.insertSeasonGuestStarRole( + database.episodeGuestStarEntityQueries.insertGuestStar( showId = showId.toLong(), season = season.toLong(), creditId = role.creditId, episode = episode.toLong(), - episodeCount = role.totalEpisodes?.toLong(), +// episodeCount = role.totalEpisodes?.toLong(), displayOrder = role.order?.toLong() ?: -1, ) insertedCreditIds += role.creditId @@ -203,7 +204,7 @@ class ProdPersonDao( ): Flow> = database .transactionWithResult { database - .seasonGuestStarRoleEntityQueries + .episodeGuestStarEntityQueries .fetchSeasonGuestStars( season = season.toLong(), showId = showId.toLong(), @@ -225,8 +226,47 @@ class ProdPersonDao( PersonRole.SeriesActor( character = firstRole.character, creditId = firstRole.creditId, - totalEpisodes = firstRole.episodeCount?.toInt(), - order = firstRole.displayOrder.toInt(), + totalEpisodes = firstRole.totalEpisodes.toInt(), + order = firstRole.displayOrder?.toInt(), + ), + ), + ) + } + } + } + + override fun fetchEpisodeGuestStars( + showId: Int, + season: Int, + episode: Int, + ): Flow> = database + .transactionWithResult { + database + .episodeGuestStarEntityQueries + .fetchEpisodeGuestStars( + season = season.toLong(), + showId = showId.toLong(), + episode = episode.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 = null, + order = firstRole.displayOrder?.toInt(), ), ), ) diff --git a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeGuestStarEntity.sq b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeGuestStarEntity.sq new file mode 100644 index 000000000..cc45a1b43 --- /dev/null +++ b/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeGuestStarEntity.sq @@ -0,0 +1,30 @@ +CREATE TABLE EpisodeGuestStarEntity( + showId INTEGER NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + creditId TEXT NOT NULL REFERENCES PersonRoleEntity(creditId), + displayOrder INTEGER NOT NULL, + PRIMARY KEY (showId, season, episode, creditId) +); + +insertGuestStar: +INSERT OR REPLACE INTO EpisodeGuestStarEntity(showId, season, episode, creditId, displayOrder) +VALUES (?,?, ?,?, ?); + +fetchSeasonGuestStars: +SELECT DISTINCT p.*, r.creditId, r.character, COUNT(m.episode) AS totalEpisodes, m.displayOrder +FROM PersonEntity p +JOIN PersonRoleEntity r ON p.id = r.castId +LEFT JOIN EpisodeGuestStarEntity m ON r.creditId = m.creditId +WHERE m.showId = ? AND m.season = ? +GROUP BY p.id, r.creditId, r.character +ORDER BY COUNT(m.episode) DESC, m.displayOrder ASC; + +fetchEpisodeGuestStars: +SELECT DISTINCT p.*, r.creditId, r.character, m.displayOrder +FROM PersonEntity p +JOIN PersonRoleEntity r ON p.id = r.castId +LEFT JOIN EpisodeGuestStarEntity m ON r.creditId = m.creditId +WHERE m.showId = ? AND m.season = ? AND m.episode = ? +GROUP BY p.id, r.creditId, r.character +ORDER BY m.displayOrder ASC; \ No newline at end of file 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 deleted file mode 100644 index b84c3a5b2..000000000 --- a/core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonGuestStarRoleEntity.sq +++ /dev/null @@ -1,29 +0,0 @@ -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 b94b4f1cf..418d06dcc 100644 --- a/core/database/src/commonMain/sqldelight/migrations/8.sqm +++ b/core/database/src/commonMain/sqldelight/migrations/8.sqm @@ -56,12 +56,11 @@ CREATE TABLE EpisodeEntity ( UNIQUE(id, seasonNumber, showId) ); -CREATE TABLE SeasonGuestStarRoleEntity( +CREATE TABLE EpisodeGuestStarEntity( 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) + PRIMARY KEY (showId, season, episode, creditId) ); \ No newline at end of file diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 4c3b9fabd..e3efe59ca 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -198,6 +198,8 @@ No guest appearances There are no guest appearances for %1$s + No plot available for %1$s at this time. + Navigate Up Button Movie image diff --git a/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/credit/SmallPersonItem.kt b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/credit/SmallPersonItem.kt new file mode 100644 index 000000000..c898eb4f5 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/divinelink/core/ui/credit/SmallPersonItem.kt @@ -0,0 +1,113 @@ +package com.divinelink.core.ui.credit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.graphics.Color +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.credits.PersonRole +import com.divinelink.core.model.details.Person +import com.divinelink.core.model.person.Gender +import com.divinelink.core.ui.MovieImage +import com.divinelink.core.ui.UiDrawable +import com.divinelink.core.ui.resources.core_ui_ic_female_person_placeholder +import com.divinelink.core.ui.resources.core_ui_ic_person_placeholder +import org.jetbrains.compose.resources.painterResource + +@Composable +fun SmallPersonItem( + modifier: Modifier = Modifier, + person: Person, + onClick: (Person) -> Unit, +) { + Card( + modifier = modifier, + onClick = { onClick(person) }, + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + ) { + Column( + modifier = Modifier + .padding(MaterialTheme.dimensions.keyline_8) + .wrapContentSize() + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MovieImage( + path = person.profilePath, + modifier = Modifier.height(MaterialTheme.dimensions.shortMediaCard), + errorPlaceHolder = if (person.gender == Gender.FEMALE) { + painterResource(UiDrawable.core_ui_ic_female_person_placeholder) + } else { + painterResource(UiDrawable.core_ui_ic_person_placeholder) + }, + ) + + Text( + text = person.name, + style = MaterialTheme.typography.bodySmall, + ) + + when (person.role.first()) { + is PersonRole.Crew -> Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + Text( + text = buildString { + append( + person.role + .filter { it.title != null } + .joinToString(", ") { it.title ?: "" }, + ) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is PersonRole.SeriesActor -> Row( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + Text( + text = buildString { + append( + person.role + .filter { it.title != null } + .joinToString(", ") { it.title ?: "" }, + ) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + is PersonRole.MovieActor -> Text( + text = buildString { + append( + person.role + .filter { it.title != null } + .joinToString(", ") { it.title ?: "" }, + ) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PersonRole.Unknown, + PersonRole.Director, + PersonRole.Creator, + PersonRole.Novel, + PersonRole.Screenplay, + -> Unit + } + } + } +} diff --git a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt index bb6e75335..a6b9c988d 100644 --- a/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt +++ b/feature/details/src/androidHostTest/kotlin/com/divinelink/feature/details/DetailsScreenTest.kt @@ -392,7 +392,7 @@ class DetailsScreenTest : ComposeTest() { onNodeWithTag(TestTags.Details.COLLAPSIBLE_LAYOUT).performTouchInput { swipeUp( startY = 200f, - endY = 50f, + endY = 120f, ) } @@ -622,7 +622,7 @@ class DetailsScreenTest : ComposeTest() { onNodeWithTag(TestTags.Details.COLLAPSIBLE_LAYOUT).performTouchInput { swipeUp( startY = 200f, - endY = 50f, + endY = 120f, ) } diff --git a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt index ee3edfc85..e3338e704 100644 --- a/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt +++ b/feature/episode/src/commonMain/kotlin/com/divinelink/feature/episode/ui/EpisodeContent.kt @@ -10,6 +10,9 @@ import androidx.compose.foundation.layout.PaddingValues 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.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.HorizontalDivider @@ -25,12 +28,17 @@ import androidx.compose.ui.tooling.preview.PreviewParameter 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.resources.core_model_tab_guest_stars import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.navigation.route.toPersonRoute import com.divinelink.core.ui.Previews import com.divinelink.core.ui.SharedTransitionScopeProvider +import com.divinelink.core.ui.UiString import com.divinelink.core.ui.collapsingheader.ui.DetailCollapsibleContent import com.divinelink.core.ui.components.LoadingContent import com.divinelink.core.ui.composition.PreviewLocalProvider +import com.divinelink.core.ui.credit.SmallPersonItem +import com.divinelink.core.ui.resources.core_ui_empty_plot import com.divinelink.core.ui.tab.EpisodeTabs import com.divinelink.feature.episode.EpisodeAction import com.divinelink.feature.episode.EpisodeUiState @@ -40,6 +48,8 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import com.divinelink.core.model.resources.Res as modelRes @OptIn(FlowPreview::class) @Composable @@ -110,11 +120,7 @@ fun SharedTransitionScope.EpisodeContent( ScenePeekLazyColumn( modifier = Modifier .fillMaxSize(), - contentPadding = PaddingValues( - top = MaterialTheme.dimensions.keyline_16, - start = MaterialTheme.dimensions.keyline_16, - end = MaterialTheme.dimensions.keyline_16, - ), + contentPadding = PaddingValues(top = MaterialTheme.dimensions.keyline_16), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), ) { val episode = uiState.episodes[page] @@ -128,15 +134,53 @@ fun SharedTransitionScope.EpisodeContent( } } } else { - if (!episode.overview.isNullOrEmpty()) { + item { + val overview = if (episode.overview.isNullOrEmpty()) { + stringResource(UiString.core_ui_empty_plot, episode.name) + } else { + episode.overview ?: "" + } + + Text( + modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), + text = overview, + style = MaterialTheme.typography.bodyMedium, + ) + } + + item { + HorizontalDivider( + modifier = Modifier.padding(horizontal = MaterialTheme.dimensions.keyline_16), + ) + } + + if (episode.guestStars.isNotEmpty()) { item { Text( - text = episode.overview ?: "", - style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(bottom = MaterialTheme.dimensions.keyline_4) + .padding(horizontal = MaterialTheme.dimensions.keyline_16), + text = stringResource(modelRes.string.core_model_tab_guest_stars), + style = MaterialTheme.typography.titleMedium, ) - } - item { - HorizontalDivider() + + LazyRow( + contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.dimensions.keyline_8, + ), + ) { + items( + items = episode.guestStars, + key = { it.id }, + ) { person -> + SmallPersonItem( + person = person, + onClick = { onNavigate(it.toPersonRoute()) }, + ) + } + } } } }