From 31ad4b69d7f15b27abbf468b5b0319e47db4ff98 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Fri, 23 Jan 2026 21:29:15 +0200 Subject: [PATCH 1/6] feat: add initial season detail screen --- app/build.gradle.kts | 1 + .../scenepeek/di/NavigationModule.kt | 9 ++ .../scenepeek/di/ViewModelModule.kt | 2 + .../home/navigation/NavigationRouter.kt | 2 + .../season/SeasonContentScreenshots.kt | 16 +++ .../data/media/repository/MediaRepository.kt | 5 + .../media/repository/ProdMediaRepository.kt | 9 ++ .../core/database/media/dao/MediaDao.kt | 5 + .../core/database/media/dao/ProdMediaDao.kt | 26 ++++ .../divinelink/core/database/SeasonEntity.sq | 5 + .../core/navigation/route/Navigation.kt | 8 ++ .../core/navigation/route/SeasonRoute.kt | 7 ++ .../ui/CollapsibleHeaderContent.kt | 94 +++++++++++++++ .../ui/DetailCollapsibleContent.kt | 58 +++++++++ .../core/ui/components/AppTopAppBar.kt | 18 ++- .../core/ui/extension/DateTimeExtension.kt | 12 ++ .../details/media/ui/DetailsContent.kt | 10 ++ .../ui/forms/seasons/SeasonsFormContent.kt | 30 +++-- feature/season/build.gradle.kts | 23 ++++ .../composeResources/values/strings.xml | 2 + .../divinelink/feature/season/SeasonAction.kt | 3 + .../feature/season/SeasonUiState.kt | 18 +++ .../feature/season/SeasonViewModel.kt | 51 ++++++++ .../feature/season/ui/SeasonContent.kt | 81 +++++++++++++ .../feature/season/ui/SeasonScreen.kt | 111 ++++++++++++++++++ .../ui/components/SeasonTitleDetails.kt | 45 +++++++ .../season/ui/navigation/SeasonNavigation.kt | 14 +++ .../SeasonUiStateParameterProvider.kt | 8 ++ settings.gradle.kts | 1 + 29 files changed, 664 insertions(+), 10 deletions(-) create mode 100644 app/src/screenshotTest/kotlin/com/divinelink/scenepeek/feature/season/SeasonContentScreenshots.kt create mode 100644 core/scaffold/src/commonMain/kotlin/com/divinelink/core/navigation/route/SeasonRoute.kt create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/CollapsibleHeaderContent.kt create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/collapsingheader/ui/DetailCollapsibleContent.kt create mode 100644 feature/season/build.gradle.kts create mode 100644 feature/season/src/commonMain/composeResources/values/strings.xml create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonContent.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonScreen.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/components/SeasonTitleDetails.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/navigation/SeasonNavigation.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/provider/SeasonUiStateParameterProvider.kt 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/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..b45a933bb 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 @@ -68,6 +68,11 @@ interface MediaRepository { fun fetchTvSeasons(id: Int): Flow>> + fun fetchSeason( + showId: Int, + seasonNumber: Int, + ): Flow> + /** * Add favorite [media] to local storage. */ 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..835858cb1 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 @@ -273,6 +273,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) }, 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..528ad3947 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 @@ -41,6 +41,11 @@ interface MediaDao { fun fetchSeasons(id: Int): Flow> + fun fetchSeason( + showId: Int, + seasonNumber: Int, + ): Flow + fun addToFavorites( mediaId: Int, mediaType: MediaType, 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..a78e313fa 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,6 +2,7 @@ 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 com.divinelink.core.commons.domain.DispatcherProvider import com.divinelink.core.database.Database import com.divinelink.core.database.MediaItemEntity @@ -198,6 +199,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, 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/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/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/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/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..2c0ff9a8b --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt @@ -0,0 +1,3 @@ +package com.divinelink.feature.season + +sealed interface 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..5a0fcb009 --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt @@ -0,0 +1,18 @@ +package com.divinelink.feature.season + +import com.divinelink.core.model.details.Season +import com.divinelink.core.navigation.route.Navigation + +data class SeasonUiState( + val backdropPath: String?, + val title: String, + val season: Season?, +) { + companion object { + fun initial(route: Navigation.SeasonRoute) = SeasonUiState( + backdropPath = route.backdropPath, + title = route.title, + season = null, + ) + } +} 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..9e286afde --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -0,0 +1,51 @@ +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.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 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 { uiState -> + uiState.copy(season = it) + } + }, + onFailure = { + }, + ) + } + .launchIn(viewModelScope) + } +} 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..5a887d152 --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/SeasonContent.kt @@ -0,0 +1,81 @@ +package com.divinelink.feature.season.ui + +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.navigation.route.Navigation +import com.divinelink.core.ui.Previews +import com.divinelink.core.ui.SharedTransitionScopeProvider +import com.divinelink.core.ui.collapsingheader.ui.DetailCollapsibleContent +import com.divinelink.core.ui.components.JellyseerrStatusPill +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.provider.SeasonUiStateParameterProvider + +@Composable +fun SharedTransitionScope.SeasonContent( + visibilityScope: AnimatedVisibilityScope, + uiState: SeasonUiState, + onBackdropLoaded: () -> Unit, + toolbarProgress: (Float) -> Unit, + onNavigate: (Navigation) -> Unit, + action: (SeasonAction) -> Unit, +) { + if (uiState.season == null) return + + 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 = { + }, + ) +} + +@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..329073101 --- /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 ?: ""), + 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 = { action -> + }, + 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/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") From 122c41801f8e00ffaa4f33a4f125319e7c441966 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Fri, 23 Jan 2026 22:48:24 +0200 Subject: [PATCH 2/6] feat: add season tabs --- .../composeResources/values/strings.xml | 1 + .../core/model/details/season/SeasonData.kt | 13 +++++ .../core/model/details/season/SeasonForms.kt | 9 ++++ .../divinelink/core/model/tab/SeasonTab.kt | 41 +++++++++++++++ .../core/scaffold/ScenePeekNavHost.kt | 5 +- .../divinelink/feature/season/SeasonAction.kt | 4 +- .../feature/season/SeasonUiState.kt | 16 ++++++ .../feature/season/SeasonViewModel.kt | 14 ++++++ .../feature/season/ui/SeasonContent.kt | 50 +++++++++++++++++++ .../feature/season/ui/SeasonScreen.kt | 4 +- 10 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonData.kt create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonForms.kt create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/SeasonTab.kt diff --git a/core/model/src/commonMain/composeResources/values/strings.xml b/core/model/src/commonMain/composeResources/values/strings.xml index 7e1c5ef08..d5f45352d 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 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..e82e21c18 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonData.kt @@ -0,0 +1,13 @@ +package com.divinelink.core.model.details.season + +import com.divinelink.core.model.details.Person + +sealed interface SeasonData { + data class About( + val overview: String?, + ) : SeasonData + + data class Cast( + 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..c3c246add --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/season/SeasonForms.kt @@ -0,0 +1,9 @@ +package com.divinelink.core.model.details.season + +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..3de68d368 --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/SeasonTab.kt @@ -0,0 +1,41 @@ +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 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 = "cast", + titleRes = Res.string.core_model_tab_cast, + ) + + companion object { + val entries + get() = listOf( + Episodes, + About, + Cast, + ) + } +} diff --git a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt index 8ad7d05bf..2883bc4dd 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost +import com.divinelink.core.model.home.MediaListSection import com.divinelink.core.navigation.route.Navigation typealias NavGraphExtension = NavGraphBuilder.( @@ -23,7 +24,9 @@ fun SharedTransitionScope.ScenePeekNavHost() { NavHost( navController = navController, - startDestination = Navigation.HomeRoute, + startDestination = Navigation.MediaListsRoute( + section = MediaListSection.Favorites, + ), enterTransition = { fadeIn(animationSpec = tween(durationMillis = 300, easing = LinearEasing)) }, 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 index 2c0ff9a8b..00fd131f8 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonAction.kt @@ -1,3 +1,5 @@ package com.divinelink.feature.season -sealed interface SeasonAction +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 index 5a0fcb009..0aee41861 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt @@ -1,18 +1,34 @@ 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.associate { tab -> + tab.order to when (tab) { + SeasonTab.Episodes -> SeasonForm.Loading + SeasonTab.About -> SeasonForm.Loading + SeasonTab.Cast -> 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 index 9e286afde..631e57828 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -48,4 +48,18 @@ class SeasonViewModel( } .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, + ) + } + } } 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 5a887d152..5f41fc53c 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 @@ -2,12 +2,22 @@ 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.Column 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.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.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import com.divinelink.core.designsystem.theme.dimensions @@ -16,10 +26,14 @@ 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.JellyseerrStatusPill +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.provider.SeasonUiStateParameterProvider +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch @Composable fun SharedTransitionScope.SeasonContent( @@ -31,6 +45,22 @@ fun SharedTransitionScope.SeasonContent( 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, @@ -59,6 +89,26 @@ fun SharedTransitionScope.SeasonContent( } }, 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 -> + + } + } }, ) } 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 329073101..c52b7ad87 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 @@ -81,6 +81,7 @@ fun AnimatedVisibilityScope.SeasonScreen( AppTopAppBar( scrollBehavior = scrollBehavior, text = UIText.StringText(uiState.season?.name ?: ""), + contentColor = textColor, subtitle = UIText.StringText(uiState.title), progress = toolbarProgress, onNavigateUp = { onNavigate(Navigation.Back) }, @@ -101,8 +102,7 @@ fun AnimatedVisibilityScope.SeasonScreen( uiState = uiState, onBackdropLoaded = { onBackdropLoaded = true }, toolbarProgress = { progress -> toolbarProgress = progress }, - action = { action -> - }, + action = viewModel::onAction, onNavigate = onNavigate, ) } From faded38c7b3302f63c30404dd5c3d7cf40bfa0ad Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 24 Jan 2026 01:57:27 +0200 Subject: [PATCH 3/6] feat: parse season details and implement episodes and about tab ui --- .../data/media/repository/MediaRepository.kt | 6 + .../media/repository/ProdMediaRepository.kt | 12 ++ .../divinelink/core/model/details/Episode.kt | 19 +++ .../core/model/details/SeasonDetails.kt | 16 +++ .../core/model/details/season/SeasonData.kt | 14 ++ .../core/model/details/season/SeasonForms.kt | 4 +- .../mapper/details/EpisodeResponseMapper.kt | 21 +++ .../details/SeasonDetailsResponseMapper.kt | 20 +++ .../media/model/details/DetailsResponseApi.kt | 7 +- .../model/details/season/EpisodeResponse.kt | 24 ++++ .../details/season/SeasonDetailsResponse.kt | 15 +++ .../network/media/service/MediaService.kt | 6 + .../network/media/service/ProdMediaService.kt | 12 ++ .../core/network/media/util/BuildUrl.kt | 13 ++ .../core/scaffold/ScenePeekNavHost.kt | 5 +- .../composeResources/values/strings.xml | 7 + .../core/ui/SimpleInformationRow.kt | 33 +++++ .../composeResources/values/strings.xml | 2 - .../ui/components/MovieInformationSection.kt | 29 +---- .../ui/components/TvInformationSection.kt | 10 +- .../feature/season/SeasonUiState.kt | 4 +- .../feature/season/SeasonViewModel.kt | 76 ++++++++++- .../feature/season/ui/SeasonContent.kt | 38 ++++++ .../season/ui/forms/about/AboutFormContent.kt | 121 ++++++++++++++++++ .../ui/forms/episodes/EpisodesFormContent.kt | 104 +++++++++++++++ 25 files changed, 575 insertions(+), 43 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/details/Episode.kt create mode 100644 core/model/src/commonMain/kotlin/com/divinelink/core/model/details/SeasonDetails.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/EpisodeResponseMapper.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/EpisodeResponse.kt create mode 100644 core/network/src/commonMain/kotlin/com/divinelink/core/network/media/model/details/season/SeasonDetailsResponse.kt create mode 100644 core/ui/src/commonMain/kotlin/com/divinelink/core/ui/SimpleInformationRow.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/about/AboutFormContent.kt create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt 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 b45a933bb..4b121c810 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 @@ -92,4 +93,9 @@ interface MediaRepository { ): Result suspend fun fetchGenres(mediaType: MediaType): Flow>> + + fun fetchSeasonDetails( + showId: Int, + seasonNumber: 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 835858cb1..451d8130f 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 @@ -6,6 +6,7 @@ import com.divinelink.core.database.media.dao.MediaDao 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 +15,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 @@ -291,4 +293,14 @@ class ProdMediaRepository( }, shouldFetch = { it.isEmpty() }, ) + + override fun fetchSeasonDetails(showId: Int, seasonNumber: Int): Flow> = + flow { + emit( + remote.fetchSeason( + showId = showId, + seasonNumber = seasonNumber, + ).map { it.map() }, + ) + } } 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..7be23f8da --- /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: String, + 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..f8a141d6c --- /dev/null +++ b/core/model/src/commonMain/kotlin/com/divinelink/core/model/details/SeasonDetails.kt @@ -0,0 +1,16 @@ +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?, +) 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 index e82e21c18..3b858b735 100644 --- 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 @@ -1,10 +1,24 @@ 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( 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 index c3c246add..59582120b 100644 --- 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 @@ -1,6 +1,8 @@ package com.divinelink.core.model.details.season -typealias SeasonForms = Map> +import com.divinelink.core.model.tab.SeasonTab + +typealias SeasonForms = Map> sealed interface SeasonForm { data object Loading : SeasonForm 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..63e645237 --- /dev/null +++ b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/mapper/details/SeasonDetailsResponseMapper.kt @@ -0,0 +1,20 @@ +package com.divinelink.core.network.media.mapper.details + +import com.divinelink.core.model.details.SeasonDetails +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() }, +) 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..f6866a274 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, @@ -217,12 +217,13 @@ private fun CastApi.toPerson(): Person = Person( ) @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/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..d41811383 --- /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: String, + @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..e01e4f4fa 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, + seasonNumber: 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..e4123a1d0 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,14 @@ 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, seasonNumber: Int): Result = + runCatching { + restClient.get( + url = buildSeasonDetailsUrl( + showId = showId, + seasonNumber = seasonNumber, + ), + ) + } } 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/scaffold/ScenePeekNavHost.kt b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt index 2883bc4dd..8ad7d05bf 100644 --- a/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt +++ b/core/scaffold/src/commonMain/kotlin/com/divinelink/core/scaffold/ScenePeekNavHost.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost -import com.divinelink.core.model.home.MediaListSection import com.divinelink.core.navigation.route.Navigation typealias NavGraphExtension = NavGraphBuilder.( @@ -24,9 +23,7 @@ fun SharedTransitionScope.ScenePeekNavHost() { NavHost( navController = navController, - startDestination = Navigation.MediaListsRoute( - section = MediaListSection.Favorites, - ), + startDestination = Navigation.HomeRoute, enterTransition = { fadeIn(animationSpec = tween(durationMillis = 300, easing = LinearEasing)) }, diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 5c4af8fed..a0a3f7ffa 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 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/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/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/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt index 0aee41861..fa29c2cda 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt @@ -22,8 +22,8 @@ data class SeasonUiState( season = null, selectedTab = 0, tabs = SeasonTab.entries, - forms = SeasonTab.entries.associate { tab -> - tab.order to when (tab) { + forms = SeasonTab.entries.associateWith { tab -> + when (tab) { SeasonTab.Episodes -> SeasonForm.Loading SeasonTab.About -> SeasonForm.Loading SeasonTab.Cast -> 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 index 631e57828..617d0f03d 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -4,9 +4,15 @@ 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.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 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 @@ -38,11 +44,77 @@ class SeasonViewModel( .onEach { result -> result.fold( onSuccess = { - _uiState.update { uiState -> - uiState.copy(season = it) + _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, + seasonNumber = route.seasonNumber, + ) + .distinctUntilChanged() + .catch { + Napier.d(it.message.toString()) + } + .onEach { result -> + result.fold( + onSuccess = { data -> + _uiState.update { state -> + val episodeTab = SeasonTab.Episodes + val aboutTab = SeasonTab.About + + 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, + ), + ), + ) + } + + state.copy( + forms = updatedForms, + ) } }, onFailure = { + Napier.d(it.message.toString()) }, ) } 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 5f41fc53c..bee3cfca5 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 @@ -5,10 +5,13 @@ import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement 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 @@ -21,15 +24,22 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter import com.divinelink.core.designsystem.theme.dimensions +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.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.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.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 @@ -106,7 +116,35 @@ fun SharedTransitionScope.SeasonContent( .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 -> { + } + } + SeasonForm.Error -> Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = MaterialTheme.dimensions.keyline_16) + .verticalScroll(rememberScrollState()), + ) { + BlankSlate(uiState = BlankSlateState.Contact) + } + + SeasonForm.Loading -> LoadingContent( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) + } + } } } }, 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..4029830ef --- /dev/null +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/about/AboutFormContent.kt @@ -0,0 +1,121 @@ +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..99fbf8d8c --- /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), + ) + } +} From de52dd87b2f2c88bf56f954c96a7bebe6858a4e5 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Fri, 30 Jan 2026 23:33:47 +0200 Subject: [PATCH 4/6] feat: add guest star tab on season details --- .../composeResources/values/strings.xml | 2 + .../divinelink/core/model/details/Episode.kt | 2 +- .../core/model/details/SeasonDetails.kt | 1 + .../core/model/details/season/SeasonData.kt | 4 ++ .../divinelink/core/model/tab/SeasonTab.kt | 11 +++- .../details/SeasonDetailsResponseMapper.kt | 36 ++++++++++ .../media/model/details/DetailsResponseApi.kt | 7 +- .../model/details/season/EpisodeResponse.kt | 2 +- .../composeResources/values/strings.xml | 5 ++ .../feature/season/SeasonUiState.kt | 1 + .../feature/season/SeasonViewModel.kt | 4 ++ .../feature/season/ui/SeasonContent.kt | 46 +++++++++++-- .../season/ui/forms/PeopleFormContent.kt | 66 +++++++++++++++++++ .../ui/forms/episodes/EpisodesFormContent.kt | 2 +- 14 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/PeopleFormContent.kt diff --git a/core/model/src/commonMain/composeResources/values/strings.xml b/core/model/src/commonMain/composeResources/values/strings.xml index d5f45352d..34372636d 100644 --- a/core/model/src/commonMain/composeResources/values/strings.xml +++ b/core/model/src/commonMain/composeResources/values/strings.xml @@ -39,6 +39,8 @@ Movies TV Shows + Guest stars + Unknown Requested Pending 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 7be23f8da..f9f33219d 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 @@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable data class Episode( val id: Int, val name: String, - val airDate: String, + val airDate: String?, val overview: String, val runtime: String?, val number: Int, 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 index f8a141d6c..ce402b157 100644 --- 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 @@ -13,4 +13,5 @@ data class SeasonDetails( 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 index 3b858b735..a8343b5ca 100644 --- 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 @@ -24,4 +24,8 @@ sealed interface 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/tab/SeasonTab.kt b/core/model/src/commonMain/kotlin/com/divinelink/core/model/tab/SeasonTab.kt index 3de68d368..7e821164d 100644 --- 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 @@ -4,6 +4,7 @@ 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( @@ -26,16 +27,22 @@ sealed class SeasonTab( data object Cast : SeasonTab( order = 2, - value = "cast", + 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, - Cast, + GuestStars, ) } } 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 index 63e645237..ca6a38eac 100644 --- 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 @@ -1,6 +1,10 @@ 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 @@ -17,4 +21,36 @@ fun SeasonDetailsResponse.map(): SeasonDetails = SeasonDetails( .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 characterCounts = cast + .map { it.character } + .groupingBy { it } + .eachCount() + + Person( + id = id, + name = firstCast.name, + profilePath = firstCast.profilePath, + gender = Gender.from(firstCast.gender), + knownForDepartment = firstCast.knownForDepartment, + role = characterCounts.map { (character, count) -> + PersonRole.SeriesActor( + character = character, + creditId = null, + totalEpisodes = count, + ) + }, + ) + } + .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 f6866a274..fd189aa66 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 @@ -212,7 +212,12 @@ fun CastApi.toPerson(): Person = Person( ), ) } - is CastApi.TV -> listOf(PersonRole.Unknown) + is CastApi.TV -> listOf( + PersonRole.SeriesActor( + character = character, + creditId = creditId, + ), + ) }, ) 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 d41811383..17a10295f 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 @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable @Serializable data class EpisodeResponse( val id: Int, - @SerialName("air_date") val airDate: String, + @SerialName("air_date") val airDate: String?, @SerialName("episode_type") val episodeType: String, val name: String, val overview: String, diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index a0a3f7ffa..076a8b804 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -193,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 not guest appearances for %1$s + Navigate Up Button Movie image 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 index fa29c2cda..d14e7a4ed 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonUiState.kt @@ -27,6 +27,7 @@ data class SeasonUiState( 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 index 617d0f03d..51b6b503f 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -89,6 +89,7 @@ class SeasonViewModel( _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( @@ -106,6 +107,9 @@ class SeasonViewModel( ), ), ) + this[castTab] = SeasonForm.Content( + SeasonData.GuestStars(cast = data.guestStars), + ) } state.copy( 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 bee3cfca5..e020ca999 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 @@ -4,6 +4,7 @@ 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 @@ -21,23 +22,31 @@ 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 @@ -125,8 +134,28 @@ fun SharedTransitionScope.SeasonContent( is SeasonData.About -> AboutFormContent( aboutData = form.data as SeasonData.About, ) - is SeasonData.Cast -> { - } + 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.title, + ), + ), + ) + is SeasonData.GuestStars -> PeopleFormContent( + 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.title, + ), + ), + ) } SeasonForm.Error -> Column( @@ -138,11 +167,14 @@ fun SharedTransitionScope.SeasonContent( BlankSlate(uiState = BlankSlateState.Contact) } - SeasonForm.Loading -> LoadingContent( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - ) + SeasonForm.Loading -> Box(modifier = Modifier.fillMaxSize()) { + LoadingContent( + modifier = Modifier + .padding(top = MaterialTheme.dimensions.keyline_16) + .align(Alignment.TopCenter) + .verticalScroll(rememberScrollState()), + ) + } } } } 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/episodes/EpisodesFormContent.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/ui/forms/episodes/EpisodesFormContent.kt index 99fbf8d8c..746f512db 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 @@ -81,7 +81,7 @@ fun EpisodeItem( color = MaterialTheme.colorScheme.primary, ) - episode.airDate.toLocalDate().localizeFull()?.let { airDate -> + episode.airDate?.toLocalDate().localizeFull()?.let { airDate -> Text( text = buildString { append(airDate) From d5881f774311055621903f2fc042e5571014f6da Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sat, 31 Jan 2026 00:35:29 +0200 Subject: [PATCH 5/6] feat: cache and fetch season details and episodes --- .../data/media/repository/MediaRepository.kt | 2 +- .../media/repository/ProdMediaRepository.kt | 65 ++++++++++- .../core/database/media/dao/MediaDao.kt | 30 +++++ .../core/database/media/dao/ProdMediaDao.kt | 108 ++++++++++++++++++ .../core/database/season/EpisodeEntity.sq | 42 +++++++ .../database/season/SeasonDetailsEntity.sq | 32 ++++++ .../commonMain/sqldelight/migrations/8.sqm | 28 +++++ .../divinelink/core/model/details/Episode.kt | 2 +- .../model/details/season/EpisodeResponse.kt | 2 +- .../composeResources/values/strings.xml | 2 +- .../feature/season/SeasonViewModel.kt | 79 +++++++------ .../feature/season/ui/SeasonContent.kt | 5 +- 12 files changed, 348 insertions(+), 49 deletions(-) create mode 100644 core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/EpisodeEntity.sq create mode 100644 core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonDetailsEntity.sq 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 4b121c810..bc0931381 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 @@ -97,5 +97,5 @@ interface MediaRepository { fun fetchSeasonDetails( showId: Int, seasonNumber: Int, - ): Flow> + ): 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 451d8130f..d0b2ec2d6 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,8 +3,10 @@ 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.season.SeasonDetailsEntity 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 @@ -294,13 +296,64 @@ class ProdMediaRepository( shouldFetch = { it.isEmpty() }, ) - override fun fetchSeasonDetails(showId: Int, seasonNumber: Int): Flow> = - flow { - emit( + override fun fetchSeasonDetails(showId: Int, seasonNumber: Int): Flow> = + networkBoundResource( + query = { + val episodes = dao.fetchEpisodes( + showId = showId, + seasonNumber = seasonNumber, + ) + + val details = dao.fetchSeasonDetails( + showId = showId, + seasonNumber = seasonNumber, + ) + + combine( + episodes, + details, + ) { episodes, details -> + details?.map( + episodes = episodes, + ) + } + }, + fetch = { remote.fetchSeason( showId = showId, seasonNumber = seasonNumber, - ).map { it.map() }, - ) - } + ).map { it.map() } + }, + saveFetchResult = { result -> + val data = result.data + + dao.insertSeasonDetails( + seasonDetails = data, + showId = showId, + seasonNumber = seasonNumber, + ) + dao.insertEpisodes(data.episodes) + dao.insertGuestStars( + season = seasonNumber, + showId = showId, + guestStars = data.guestStars, + ) + }, + shouldFetch = { true }, + ) } + +fun SeasonDetailsEntity.map( + episodes: List, +) = SeasonDetails( + id = id.toInt(), + name = name, + overview = overview, + posterPath = posterPath, + airDate = airDate, + episodeCount = episodeCount.toInt(), + voteAverage = voteAverage, + episodes = episodes, + totalRuntime = runtime, + guestStars = emptyList(), +) 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 528ad3947..3edfa4aed 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,12 @@ 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.Person 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 @@ -67,4 +71,30 @@ interface MediaDao { mediaType: MediaType, genres: List, ) + + fun insertEpisodes(episodes: List) + + fun fetchEpisode( + showId: Int, + episodeNumber: Int, + seasonNumber: Int, + ): Episode + + fun fetchEpisodes( + showId: Int, + seasonNumber: Int, + ): Flow> + + fun insertSeasonDetails(seasonDetails: SeasonDetails, showId: Int, seasonNumber: Int) + + fun insertGuestStars( + showId: Int, + season: Int, + guestStars: List, + ) + + fun fetchSeasonDetails( + seasonNumber: 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 a78e313fa..07b73765d 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 @@ -3,14 +3,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.Person 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 @@ -291,4 +297,106 @@ 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, seasonNumber: Int): Flow> = database + .transactionWithResult { + database + .episodeEntityQueries + .fetchEpisodes( + showId = showId.toLong(), + seasonNumber = seasonNumber.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(seasonNumber: Int, showId: Int): Flow = + database + .transactionWithResult { + database + .seasonDetailsEntityQueries + .fetchSeasonDetails( + showId = showId.toLong(), + seasonNumber = seasonNumber.toLong(), + ) + .asFlow() + .mapToOneOrNull(dispatcher.io) + } + + override fun insertGuestStars(showId: Int, season: Int, guestStars: List) { + + } + } + +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/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/migrations/8.sqm b/core/database/src/commonMain/sqldelight/migrations/8.sqm index 4903d5844..d5817390b 100644 --- a/core/database/src/commonMain/sqldelight/migrations/8.sqm +++ b/core/database/src/commonMain/sqldelight/migrations/8.sqm @@ -26,4 +26,32 @@ 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) ); \ No newline at end of file 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 f9f33219d..e5c67dab5 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 @@ -10,7 +10,7 @@ data class Episode( val overview: String, val runtime: String?, val number: Int, - val seasonNumber: String, + val seasonNumber: Int, val showId: Int, val stillPath: String?, val voteAverage: String?, 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 17a10295f..5642c1ace 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 @@ -14,7 +14,7 @@ data class EpisodeResponse( val overview: String, val runtime: Int?, @SerialName("episode_number") val episodeNumber: Int, - @SerialName("season_number") val seasonNumber: String, + @SerialName("season_number") val seasonNumber: Int, @SerialName("show_id") val showId: Int, @SerialName("still_path") val stillPath: String?, @SerialName("vote_average") val voteAverage: String?, diff --git a/core/ui/src/commonMain/composeResources/values/strings.xml b/core/ui/src/commonMain/composeResources/values/strings.xml index 076a8b804..4c3b9fabd 100644 --- a/core/ui/src/commonMain/composeResources/values/strings.xml +++ b/core/ui/src/commonMain/composeResources/values/strings.xml @@ -196,7 +196,7 @@ No cast available We don't have any cast information for %1$s No guest appearances - There are not guest appearances for %1$s + There are no guest appearances for %1$s Navigate Up Button 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 index 51b6b503f..8429cfba3 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -4,11 +4,13 @@ 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 @@ -84,43 +86,11 @@ class SeasonViewModel( Napier.d(it.message.toString()) } .onEach { result -> - result.fold( - onSuccess = { data -> - _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, - ) - } - }, - onFailure = { - Napier.d(it.message.toString()) - }, - ) + 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) } @@ -138,4 +108,39 @@ class SeasonViewModel( ) } } + + 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 index e020ca999..b8729157b 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 @@ -141,18 +141,19 @@ fun SharedTransitionScope.SeasonContent( title = UIText.ResourceText(UiString.core_ui_no_cast_available), description = UIText.ResourceText( UiString.core_ui_no_cast_available_description, - uiState.title, + 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.title, + uiState.season.name, ), ), ) From 3de800188336ec9cf33cab262b43a15405a45ad2 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 1 Feb 2026 20:47:14 +0200 Subject: [PATCH 6/6] feat: implement caching for season guest stars --- .../data/media/ProdMediaRepositoryTest.kt | 15 ++- .../data/media/repository/MediaRepository.kt | 2 +- .../media/repository/ProdMediaRepository.kt | 97 ++++++++----------- .../core/database/media/dao/MediaDao.kt | 15 ++- .../core/database/media/dao/ProdMediaDao.kt | 60 +++++------- .../media/mapper/EpisodeEntityMapper.kt | 19 ++++ .../media/mapper/SeasonDetailsEntityMapper.kt | 22 +++++ .../core/database/person/PersonDao.kt | 13 +++ .../core/database/person/ProdPersonDao.kt | 95 ++++++++++++++++++ .../season/SeasonGuestStarRoleEntity.sq | 29 ++++++ .../commonMain/sqldelight/migrations/8.sqm | 10 ++ .../core/model/credits/PersonRole.kt | 3 +- .../details/SeasonDetailsResponseMapper.kt | 10 +- .../media/model/details/DetailsResponseApi.kt | 1 + .../media/model/details/credits/CastApi.kt | 7 +- .../network/media/service/MediaService.kt | 2 +- .../network/media/service/ProdMediaService.kt | 20 ++-- .../core/ui/credit/PersonItemPreviews.kt | 12 +++ .../ui/provider/PersonParameterProvider.kt | 6 +- detekt.yml | 2 +- .../CreditsUiStateParameterProvider.kt | 4 + .../feature/season/SeasonViewModel.kt | 6 +- .../season/ui/forms/about/AboutFormContent.kt | 5 +- 23 files changed, 322 insertions(+), 133 deletions(-) create mode 100644 core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/EpisodeEntityMapper.kt create mode 100644 core/database/src/commonMain/kotlin/com/divinelink/core/database/media/mapper/SeasonDetailsEntityMapper.kt create mode 100644 core/database/src/commonMain/sqldelight/com/divinelink/core/database/season/SeasonGuestStarRoleEntity.sq 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 bc0931381..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 @@ -96,6 +96,6 @@ interface MediaRepository { fun fetchSeasonDetails( showId: Int, - seasonNumber: 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 d0b2ec2d6..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,10 +3,10 @@ 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.season.SeasonDetailsEntity +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 @@ -37,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 { @@ -296,64 +297,48 @@ class ProdMediaRepository( shouldFetch = { it.isEmpty() }, ) - override fun fetchSeasonDetails(showId: Int, seasonNumber: Int): Flow> = - networkBoundResource( - query = { - val episodes = dao.fetchEpisodes( - showId = showId, - seasonNumber = seasonNumber, + 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, + ) - val details = dao.fetchSeasonDetails( - showId = showId, - seasonNumber = seasonNumber, - ) + dao.insertEpisodes(data.episodes) - combine( - episodes, - details, - ) { episodes, details -> - details?.map( - episodes = episodes, - ) - } - }, - fetch = { - remote.fetchSeason( - showId = showId, - seasonNumber = seasonNumber, - ).map { it.map() } - }, - saveFetchResult = { result -> - val data = result.data - - dao.insertSeasonDetails( - seasonDetails = data, - showId = showId, - seasonNumber = seasonNumber, - ) - dao.insertEpisodes(data.episodes) - dao.insertGuestStars( - season = seasonNumber, + data.episodes.forEach { episode -> + personDao.insertGuestStars( + season = season, showId = showId, + episode = episode.number, guestStars = data.guestStars, ) - }, - shouldFetch = { true }, - ) + } + }, + shouldFetch = { true }, + ) } - -fun SeasonDetailsEntity.map( - episodes: List, -) = SeasonDetails( - id = id.toInt(), - name = name, - overview = overview, - posterPath = posterPath, - airDate = airDate, - episodeCount = episodeCount.toInt(), - voteAverage = voteAverage, - episodes = episodes, - totalRuntime = runtime, - guestStars = emptyList(), -) 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 3edfa4aed..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,10 +1,11 @@ +@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.Person import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.jellyseerr.media.SeasonRequest @@ -82,19 +83,17 @@ interface MediaDao { fun fetchEpisodes( showId: Int, - seasonNumber: Int, + season: Int, ): Flow> - fun insertSeasonDetails(seasonDetails: SeasonDetails, showId: Int, seasonNumber: Int) - - fun insertGuestStars( + fun insertSeasonDetails( + seasonDetails: SeasonDetails, showId: Int, - season: Int, - guestStars: List, + seasonNumber: Int, ) fun fetchSeasonDetails( - seasonNumber: Int, + 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 07b73765d..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 @@ -14,7 +14,6 @@ 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.Person import com.divinelink.core.model.details.Season import com.divinelink.core.model.details.SeasonDetails import com.divinelink.core.model.jellyseerr.media.JellyseerrStatus @@ -317,7 +316,11 @@ class ProdMediaDao( } } - override fun fetchEpisode(showId: Int, episodeNumber: Int, seasonNumber: Int): Episode = database + override fun fetchEpisode( + showId: Int, + episodeNumber: Int, + seasonNumber: Int, + ): Episode = database .transactionWithResult { database .episodeEntityQueries @@ -330,13 +333,16 @@ class ProdMediaDao( .map() } - override fun fetchEpisodes(showId: Int, seasonNumber: Int): Flow> = database + override fun fetchEpisodes( + showId: Int, + season: Int, + ): Flow> = database .transactionWithResult { database .episodeEntityQueries .fetchEpisodes( showId = showId.toLong(), - seasonNumber = seasonNumber.toLong(), + seasonNumber = season.toLong(), ) .asFlow() .mapToList(dispatcher.io) @@ -367,36 +373,18 @@ class ProdMediaDao( ) } - override fun fetchSeasonDetails(seasonNumber: Int, showId: Int): Flow = - database - .transactionWithResult { - database - .seasonDetailsEntityQueries - .fetchSeasonDetails( - showId = showId.toLong(), - seasonNumber = seasonNumber.toLong(), - ) - .asFlow() - .mapToOneOrNull(dispatcher.io) - } - - override fun insertGuestStars(showId: Int, season: Int, guestStars: List) { - - } - + override fun fetchSeasonDetails( + season: Int, + showId: Int, + ): Flow = database + .transactionWithResult { + database + .seasonDetailsEntityQueries + .fetchSeasonDetails( + showId = showId.toLong(), + seasonNumber = season.toLong(), + ) + .asFlow() + .mapToOneOrNull(dispatcher.io) + } } - -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/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/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 d5817390b..b94b4f1cf 100644 --- a/core/database/src/commonMain/sqldelight/migrations/8.sqm +++ b/core/database/src/commonMain/sqldelight/migrations/8.sqm @@ -54,4 +54,14 @@ CREATE TABLE EpisodeEntity ( 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/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/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 index ca6a38eac..1c13ae6c7 100644 --- 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 @@ -30,8 +30,7 @@ private fun aggregateGuestStars(allEpisodes: List): List val firstCast = cast.first() - val characterCounts = cast - .map { it.character } + val roles = cast .groupingBy { it } .eachCount() @@ -41,11 +40,12 @@ private fun aggregateGuestStars(allEpisodes: List): List + role = roles.map { (cast, count) -> PersonRole.SeriesActor( - character = character, - creditId = null, + character = cast.character, + creditId = cast.creditId, totalEpisodes = count, + order = cast.order, ) }, ) 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 fd189aa66..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 @@ -216,6 +216,7 @@ fun CastApi.toPerson(): Person = Person( PersonRole.SeriesActor( character = character, creditId = creditId, + order = order, ), ) }, 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/service/MediaService.kt b/core/network/src/commonMain/kotlin/com/divinelink/core/network/media/service/MediaService.kt index e01e4f4fa..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 @@ -81,6 +81,6 @@ interface MediaService { suspend fun fetchSeason( showId: Int, - seasonNumber: 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 e4123a1d0..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 @@ -256,13 +256,15 @@ class ProdMediaService(private val restClient: TMDbClient) : MediaService { restClient.get(url = buildGenreUrl(mediaType)) } - override suspend fun fetchSeason(showId: Int, seasonNumber: Int): Result = - runCatching { - restClient.get( - url = buildSeasonDetailsUrl( - showId = showId, - seasonNumber = seasonNumber, - ), - ) - } + override suspend fun fetchSeason( + showId: Int, + season: Int, + ): Result = runCatching { + restClient.get( + url = buildSeasonDetailsUrl( + showId = showId, + seasonNumber = season, + ), + ) + } } 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/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/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt index 8429cfba3..9b73119cc 100644 --- a/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt +++ b/feature/season/src/commonMain/kotlin/com/divinelink/feature/season/SeasonViewModel.kt @@ -79,7 +79,7 @@ class SeasonViewModel( repository.fetchSeasonDetails( showId = route.showId, - seasonNumber = route.seasonNumber, + season = route.seasonNumber, ) .distinctUntilChanged() .catch { @@ -109,9 +109,7 @@ class SeasonViewModel( } } - private fun handleSuccessResponse( - data: SeasonDetails, - ) { + private fun handleSuccessResponse(data: SeasonDetails) { _uiState.update { state -> val episodeTab = SeasonTab.Episodes val aboutTab = SeasonTab.About 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 index 4029830ef..2e5fb93df 100644 --- 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 @@ -48,7 +48,6 @@ fun AboutFormContent( ), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16), ) { - if (!aboutData.overview.isNullOrEmpty()) { item { Text( @@ -72,9 +71,7 @@ fun AboutFormContent( } @Composable -fun SeasonAboutInformation( - info: SeasonInformation, -) { +fun SeasonAboutInformation(info: SeasonInformation) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_16),