From 02ffe7f9a70030c6c82cc12a18942288e508a4d8 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Thu, 18 Jul 2024 17:40:34 +0300 Subject: [PATCH 01/10] feat: add credits module and screen --- app/build.gradle.kts | 1 + .../movierama/navigation/MainGraph.kt | 2 + core/model/build.gradle.kts | 3 + .../core/model/credits/AggregateCredits.kt | 2 + .../core/model/credits/PersonRole.kt | 6 ++ .../model/credits/SeriesCrewDepartment.kt | 2 + .../divinelink/core/model/details/Person.kt | 2 + .../ui/components/details/cast/CastList.kt | 82 ++++++++++++++++--- .../ui/components/scaffold/AppScaffold.kt | 36 ++++++++ core/ui/src/main/res/values/strings.xml | 1 + docs/MainNavGraph.html | 3 +- docs/MainNavGraph.mmd | 3 +- feature/credits/.gitignore | 1 + feature/credits/build.gradle.kts | 21 +++++ .../credits/navigation/CreditsGraph.kt | 7 ++ .../credits/navigation/CreditsNavArguments.kt | 9 ++ .../feature/credits/ui/CreditsScreen.kt | 58 +++++++++++++ .../credits/src/main/res/values/strings.xml | 4 + feature/details/build.gradle.kts | 3 +- .../feature/details/ui/DetailsContent.kt | 28 +++++-- .../feature/details/ui/DetailsScreen.kt | 12 +++ .../feature/details/ui/DetailsViewModel.kt | 4 +- .../feature/details/ui/DetailsViewState.kt | 4 +- .../{SettingsNavGraph.kt => SettingsGraph.kt} | 0 settings.gradle.kts | 1 + 25 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt create mode 100644 feature/credits/.gitignore create mode 100644 feature/credits/build.gradle.kts create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsGraph.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt create mode 100644 feature/credits/src/main/res/values/strings.xml rename feature/settings/src/main/kotlin/com/divinelink/feature/settings/navigation/{SettingsNavGraph.kt => SettingsGraph.kt} (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 420c5cde..8ac164d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { implementation(projects.feature.watchlist) implementation(projects.feature.details) implementation(projects.feature.settings) + implementation(projects.feature.credits) // Firebase implementation(platform(libs.firebase.bom)) diff --git a/app/src/main/kotlin/com/andreolas/movierama/navigation/MainGraph.kt b/app/src/main/kotlin/com/andreolas/movierama/navigation/MainGraph.kt index d2fd9629..c795b9e4 100644 --- a/app/src/main/kotlin/com/andreolas/movierama/navigation/MainGraph.kt +++ b/app/src/main/kotlin/com/andreolas/movierama/navigation/MainGraph.kt @@ -1,5 +1,6 @@ package com.andreolas.movierama.navigation +import com.divinelink.feature.credits.screens.destinations.CreditsScreenDestination import com.divinelink.feature.details.screens.destinations.DetailsScreenDestination import com.divinelink.feature.settings.screens.navgraphs.SettingsNavGraph import com.divinelink.ui.screens.destinations.WatchlistScreenDestination @@ -11,6 +12,7 @@ import com.ramcosta.composedestinations.annotation.NavHostGraph annotation class MainGraph { @ExternalDestination @ExternalDestination + @ExternalDestination @ExternalNavGraph companion object Includes } diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index a4edfb07..0cc7e624 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -1,7 +1,10 @@ plugins { alias(libs.plugins.divinelink.android.library) + alias(libs.plugins.kotlin.serialization) } dependencies { testImplementation(projects.core.testing) + + implementation(libs.kotlinx.serialization.json) } diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/credits/AggregateCredits.kt b/core/model/src/main/kotlin/com/divinelink/core/model/credits/AggregateCredits.kt index 6316407f..7bf95909 100644 --- a/core/model/src/main/kotlin/com/divinelink/core/model/credits/AggregateCredits.kt +++ b/core/model/src/main/kotlin/com/divinelink/core/model/credits/AggregateCredits.kt @@ -1,7 +1,9 @@ package com.divinelink.core.model.credits import com.divinelink.core.model.details.Person +import kotlinx.serialization.Serializable +@Serializable data class AggregateCredits( val cast: List, val crewDepartments: List, diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/credits/PersonRole.kt b/core/model/src/main/kotlin/com/divinelink/core/model/credits/PersonRole.kt index 01ddaa00..1b002d58 100644 --- a/core/model/src/main/kotlin/com/divinelink/core/model/credits/PersonRole.kt +++ b/core/model/src/main/kotlin/com/divinelink/core/model/credits/PersonRole.kt @@ -1,15 +1,21 @@ package com.divinelink.core.model.credits +import kotlinx.serialization.Serializable + +@Serializable sealed class PersonRole(val title: String?) { + @Serializable data class SeriesActor( val character: String?, val creditId: String? = null, val totalEpisodes: Int? = null, ) : PersonRole(character) + @Serializable data class MovieActor(val character: String?) : PersonRole(character) + @Serializable data class Crew( val job: String?, val creditId: String?, diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt b/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt index 78863d0f..98e4ab9f 100644 --- a/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt +++ b/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt @@ -1,7 +1,9 @@ package com.divinelink.core.model.credits import com.divinelink.core.model.details.Person +import kotlinx.serialization.Serializable +@Serializable data class SeriesCrewDepartment( val department: String, val crewList: List, diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/details/Person.kt b/core/model/src/main/kotlin/com/divinelink/core/model/details/Person.kt index cc75a7be..ca722a38 100644 --- a/core/model/src/main/kotlin/com/divinelink/core/model/details/Person.kt +++ b/core/model/src/main/kotlin/com/divinelink/core/model/details/Person.kt @@ -1,7 +1,9 @@ package com.divinelink.core.model.details import com.divinelink.core.model.credits.PersonRole +import kotlinx.serialization.Serializable +@Serializable data class Person( val id: Long, val name: String, diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt index f9aebe9b..48eb60ce 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt @@ -2,41 +2,69 @@ package com.divinelink.core.ui.components.details.cast 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.divinelink.core.designsystem.theme.ListPaddingValues +import com.divinelink.core.designsystem.theme.AppTheme import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.credits.PersonRole import com.divinelink.core.model.details.Person +import com.divinelink.core.ui.Previews import com.divinelink.core.ui.R @Composable -fun CastList(cast: List) { +fun CastList( + cast: List, + onViewAllClick: () -> Unit, +) { Column( modifier = Modifier - .padding(top = MaterialTheme.dimensions.keyline_16) + .padding(top = MaterialTheme.dimensions.keyline_4) .fillMaxWidth(), ) { if (cast.isNotEmpty()) { - Text( + Row( modifier = Modifier + .fillMaxWidth() .padding(horizontal = MaterialTheme.dimensions.keyline_12), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - text = stringResource(id = R.string.details__cast_title), - ) + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + text = stringResource(id = R.string.details__cast_title), + ) + Spacer(modifier = Modifier.weight(1f)) + TextButton( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + onClick = onViewAllClick, + ) { + Text(stringResource(id = R.string.core_ui_view_all)) + } + } LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = ListPaddingValues, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + contentPadding = PaddingValues( + top = MaterialTheme.dimensions.keyline_4, + start = MaterialTheme.dimensions.keyline_12, + end = MaterialTheme.dimensions.keyline_12, + bottom = MaterialTheme.dimensions.keyline_12, + ), ) { items( items = cast, @@ -48,3 +76,35 @@ fun CastList(cast: List) { } } } + +@Previews +@Composable +private fun CastListPreview() { + AppTheme { + Surface { + CastList( + cast = listOf( + Person( + id = 1, + name = "John Doe", + profilePath = null, + role = PersonRole.SeriesActor( + character = "Character Name", + totalEpisodes = 10, + ), + ), + Person( + id = 2, + name = "Jane Doe", + profilePath = "/profile.jpg", + role = PersonRole.SeriesActor( + character = "Character Name", + totalEpisodes = 10, + ), + ), + ), + onViewAllClick = {}, + ) + } + } +} diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt new file mode 100644 index 00000000..b9226b9c --- /dev/null +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt @@ -0,0 +1,36 @@ +package com.divinelink.core.ui.components.scaffold + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppScaffold( + modifier: Modifier = Modifier, + topBar: @Composable (TopAppBarScrollBehavior, TopAppBarColors) -> Unit = { _, _ -> }, + content: @Composable (PaddingValues) -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + val topAppBarColor = TopAppBarDefaults.topAppBarColors( + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ) + + Scaffold( + modifier = modifier + .navigationBarsPadding() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { topBar(scrollBehavior, topAppBarColor) }, + ) { paddingValues -> + content(paddingValues) + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index e5023961..36c1501e 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Logout Something when wrong. Please try again. Password + View all An error occurred diff --git a/docs/MainNavGraph.html b/docs/MainNavGraph.html index 73ab9ec3..ae1add6f 100644 --- a/docs/MainNavGraph.html +++ b/docs/MainNavGraph.html @@ -14,12 +14,13 @@ main(["MainGraph"]) -- "start" --- home_screen("HomeScreen") main(["MainGraph"]) --- details_screen_destination("DetailsScreen 🧩") main(["MainGraph"]) --- watchlist_screen_destination("WatchlistScreen 🧩") +main(["MainGraph"]) --- credits_screen_destination("CreditsScreen 🧩") main(["MainGraph"]) --- settings_nav_g(["SettingsGraph 🧩"]) click settings_nav_g "SettingsNavGraph.html" "See SettingsGraph details" _blank classDef destination fill:#5383EC,stroke:#ffffff; -class home_screen,details_screen_destination,watchlist_screen_destination destination; +class home_screen,details_screen_destination,watchlist_screen_destination,credits_screen_destination destination; classDef navgraph fill:#63BC76,stroke:#ffffff; class main,settings_nav_g navgraph; diff --git a/docs/MainNavGraph.mmd b/docs/MainNavGraph.mmd index d3f47fd4..1762a4c0 100644 --- a/docs/MainNavGraph.mmd +++ b/docs/MainNavGraph.mmd @@ -6,11 +6,12 @@ graph TD main(["MainGraph"]) -- "start" --- home_screen("HomeScreen") main(["MainGraph"]) --- details_screen_destination("DetailsScreen 🧩") main(["MainGraph"]) --- watchlist_screen_destination("WatchlistScreen 🧩") +main(["MainGraph"]) --- credits_screen_destination("CreditsScreen 🧩") main(["MainGraph"]) --- settings_nav_g(["SettingsGraph 🧩"]) click settings_nav_g "SettingsNavGraph.mmd" "See SettingsGraph details" _blank classDef destination fill:#5383EC,stroke:#ffffff; -class home_screen,details_screen_destination,watchlist_screen_destination destination; +class home_screen,details_screen_destination,watchlist_screen_destination,credits_screen_destination destination; classDef navgraph fill:#63BC76,stroke:#ffffff; class main,settings_nav_g navgraph; diff --git a/feature/credits/.gitignore b/feature/credits/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/credits/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/credits/build.gradle.kts b/feature/credits/build.gradle.kts new file mode 100644 index 00000000..1d7d3ba3 --- /dev/null +++ b/feature/credits/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.divinelink.android.feature) + alias(libs.plugins.divinelink.android.library.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) +} + +ksp { + arg("compose-destinations.moduleName", "feature:credits") + + arg("compose-destinations.htmlMermaidGraph", "$rootDir/docs") + arg("compose-destinations.mermaidGraph", "$rootDir/docs") + + arg("compose-destinations.codeGenPackageName", "com.divinelink.feature.credits.screens") +} + +dependencies { + implementation(libs.kotlinx.serialization.json) + + testImplementation(projects.core.testing) +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsGraph.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsGraph.kt new file mode 100644 index 00000000..a86cb6ea --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsGraph.kt @@ -0,0 +1,7 @@ +package com.divinelink.feature.credits.navigation + +import com.ramcosta.composedestinations.annotation.NavGraph +import com.ramcosta.composedestinations.annotation.RootGraph + +@NavGraph(start = true) +annotation class CreditsGraph diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt new file mode 100644 index 00000000..8082d4aa --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt @@ -0,0 +1,9 @@ +package com.divinelink.feature.credits.navigation + +import com.divinelink.core.model.credits.AggregateCredits +import com.divinelink.core.model.media.MediaType + +data class CreditsNavArguments( + val mediaType: MediaType? = null, + val aggregateCredits: AggregateCredits? = null, +) diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt new file mode 100644 index 00000000..c7255e85 --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt @@ -0,0 +1,58 @@ +package com.divinelink.feature.credits.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import com.divinelink.core.ui.components.scaffold.AppScaffold +import com.divinelink.feature.credits.R +import com.divinelink.feature.credits.navigation.CreditsGraph +import com.divinelink.feature.credits.navigation.CreditsNavArguments +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.divinelink.core.ui.R as uiR + +// TODO Check we could add deep link +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Destination( + start = true, + navArgs = CreditsNavArguments::class, +) +fun CreditsScreen(navigator: DestinationsNavigator) { + AppScaffold( + topBar = { scrollBehaviour, color -> + TopAppBar( + scrollBehavior = scrollBehaviour, + colors = color, + title = { + Text( + text = stringResource(id = R.string.feature_credits_cast_and_crew_title), + maxLines = 2, + style = MaterialTheme.typography.titleLarge, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton( + onClick = { navigator.navigateUp() }, + ) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + stringResource(uiR.string.core_ui_navigate_up_button_content_description), + ) + } + }, + + ) + }, + ) { paddingValues -> + } +} diff --git a/feature/credits/src/main/res/values/strings.xml b/feature/credits/src/main/res/values/strings.xml new file mode 100644 index 00000000..3cd9a5a5 --- /dev/null +++ b/feature/credits/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Cast & Crew + \ No newline at end of file diff --git a/feature/details/build.gradle.kts b/feature/details/build.gradle.kts index 5995f9e2..9ceaeff4 100644 --- a/feature/details/build.gradle.kts +++ b/feature/details/build.gradle.kts @@ -21,8 +21,7 @@ dependencies { implementation(projects.core.model) implementation(projects.feature.settings) - - implementation(libs.timber) + implementation(projects.feature.credits) testImplementation(projects.core.testing) } diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt index 2ce058dc..513be78b 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt @@ -108,6 +108,7 @@ fun DetailsContent( onAddRateClicked: () -> Unit, onAddToWatchlistClicked: () -> Unit, requestMedia: (List) -> Unit, + viewAllCreditsClicked: () -> Unit, ) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) var showDropdownMenu by remember { mutableStateOf(false) } @@ -180,13 +181,14 @@ fun DetailsContent( modifier = Modifier.padding(paddingValues = paddingValues), mediaDetails = mediaDetails, userDetails = viewState.userDetails, - tvCredits = viewState.tvCredits, + tvCredits = viewState.tvCredits?.cast, similarMoviesList = viewState.similarMovies, reviewsList = viewState.reviews, trailer = viewState.trailer, onSimilarMovieClicked = onSimilarMovieClicked, onAddRateClicked = onAddRateClicked, onAddToWatchlistClicked = onAddToWatchlistClicked, + viewAllCreditsClicked = viewAllCreditsClicked, ) } } @@ -254,6 +256,7 @@ fun MediaDetailsContent( onSimilarMovieClicked: (MediaItem.Media) -> Unit, onAddRateClicked: () -> Unit, onAddToWatchlistClicked: () -> Unit, + viewAllCreditsClicked: () -> Unit, ) { val showStickyPlayer = remember { mutableStateOf(false) } @@ -333,14 +336,20 @@ fun MediaDetailsContent( modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_16), thickness = MaterialTheme.dimensions.keyline_1, ) - CastList(cast = tvCredits.take(30)) // This is temporary + CastList( + cast = tvCredits.take(30), + onViewAllClick = viewAllCreditsClicked, + ) // This is temporary CreatorsItem(creators = mediaDetails.creators) } else if (mediaDetails is Movie) { HorizontalDivider( modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_16), thickness = MaterialTheme.dimensions.keyline_1, ) - CastList(cast = mediaDetails.cast) + CastList( + cast = mediaDetails.cast, + onViewAllClick = viewAllCreditsClicked, + ) mediaDetails.director?.let { DirectorItem(director = it) } @@ -542,6 +551,7 @@ private fun DetailsContentPreview( onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } } @@ -686,7 +696,7 @@ class DetailsViewStateProvider : PreviewParameterProvider { DetailsViewState( mediaId = popularMovie.id, mediaType = MediaType.MOVIE, - tvCredits = emptyList(), + tvCredits = null, isLoading = true, ), @@ -699,7 +709,7 @@ class DetailsViewStateProvider : PreviewParameterProvider { watchlist = false, ), mediaType = MediaType.MOVIE, - tvCredits = emptyList(), + tvCredits = null, mediaDetails = movieDetails, ), @@ -707,7 +717,7 @@ class DetailsViewStateProvider : PreviewParameterProvider { mediaId = popularMovie.id, mediaType = MediaType.TV, mediaDetails = TheOffice(), - tvCredits = emptyList(), + tvCredits = null, similarMovies = similarMovies, ), @@ -716,7 +726,7 @@ class DetailsViewStateProvider : PreviewParameterProvider { mediaType = MediaType.MOVIE, mediaDetails = movieDetails, similarMovies = similarMovies, - tvCredits = emptyList(), + tvCredits = null, reviews = reviews, ), @@ -731,14 +741,14 @@ class DetailsViewStateProvider : PreviewParameterProvider { rating = 9.0f, watchlist = true, ), - tvCredits = emptyList(), + tvCredits = null, reviews = reviews, ), DetailsViewState( mediaId = popularMovie.id, mediaType = MediaType.MOVIE, - tvCredits = emptyList(), + tvCredits = null, error = UIText.StringText("Something went wrong."), ), ) diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt index 8dc0b1ee..ef4a2b34 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.hilt.navigation.compose.hiltViewModel import com.divinelink.core.ui.TestTags +import com.divinelink.feature.credits.navigation.CreditsNavArguments +import com.divinelink.feature.credits.screens.destinations.CreditsScreenDestination import com.divinelink.feature.details.navigation.DetailsGraph import com.divinelink.feature.details.screens.destinations.DetailsScreenDestination import com.divinelink.feature.details.ui.rate.RateModalBottomSheet @@ -99,5 +101,15 @@ fun DetailsScreen( onAddRateClicked = viewModel::onAddRateClicked, onAddToWatchlistClicked = viewModel::onAddToWatchlist, requestMedia = viewModel::onRequestMedia, + viewAllCreditsClicked = { + navigator.navigate( + CreditsScreenDestination( + CreditsNavArguments( + mediaType = viewState.value.mediaType, + aggregateCredits = viewState.value.tvCredits!!, + ), + ), + ) + }, ) } diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt index e64a4c5f..2f481245 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt @@ -117,9 +117,7 @@ class DetailsViewModel @Inject constructor( is MovieDetailsResult.CreditsSuccess -> { val credits = (result.data as MovieDetailsResult.CreditsSuccess).aggregateCredits - viewState.copy( - tvCredits = credits.cast, - ) + viewState.copy(tvCredits = credits) } is MovieDetailsResult.Failure.FatalError -> viewState.copy( diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewState.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewState.kt index d4638b51..1093ccd7 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewState.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewState.kt @@ -1,10 +1,10 @@ package com.divinelink.feature.details.ui import com.divinelink.core.model.account.AccountMediaDetails +import com.divinelink.core.model.credits.AggregateCredits import com.divinelink.core.model.details.DetailsMenuOptions import com.divinelink.core.model.details.MediaDetails import com.divinelink.core.model.details.Movie -import com.divinelink.core.model.details.Person import com.divinelink.core.model.details.Review import com.divinelink.core.model.details.TV import com.divinelink.core.model.details.video.Video @@ -19,7 +19,7 @@ data class DetailsViewState( val mediaId: Int, val mediaDetails: MediaDetails? = null, val userDetails: AccountMediaDetails? = null, - val tvCredits: List? = null, + val tvCredits: AggregateCredits? = null, val reviews: List? = null, val similarMovies: List? = null, val trailer: Video? = null, diff --git a/feature/settings/src/main/kotlin/com/divinelink/feature/settings/navigation/SettingsNavGraph.kt b/feature/settings/src/main/kotlin/com/divinelink/feature/settings/navigation/SettingsGraph.kt similarity index 100% rename from feature/settings/src/main/kotlin/com/divinelink/feature/settings/navigation/SettingsNavGraph.kt rename to feature/settings/src/main/kotlin/com/divinelink/feature/settings/navigation/SettingsGraph.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index e3173067..c68a75a5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,3 +34,4 @@ include(":core:testing") include(":feature:details") include(":feature:settings") include(":feature:watchlist") +include(":feature:credits") From c99764e03ac0d334636bb81ca576d45589347828 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Thu, 18 Jul 2024 18:05:37 +0300 Subject: [PATCH 02/10] feat: setup credits screen --- core/domain/build.gradle.kts | 2 +- .../domain/credits/FetchCreditsUseCase.kt | 23 ++ .../model/credits/SeriesCrewDepartment.kt | 5 +- .../kotlin/com/divinelink/core/ui/TestTags.kt | 4 + .../ui/components/scaffold/AppScaffold.kt | 2 + feature/credits/build.gradle.kts | 1 + .../credits/navigation/CreditsNavArguments.kt | 3 +- .../feature/credits/ui/CreditsContent.kt | 196 ++++++++++++++++++ .../feature/credits/ui/CreditsScreen.kt | 21 +- .../feature/credits/ui/CreditsTab.kt | 12 ++ .../feature/credits/ui/CreditsTabs.kt | 33 +++ .../feature/credits/ui/CreditsUiContent.kt | 9 + .../feature/credits/ui/CreditsUiState.kt | 21 ++ .../feature/credits/ui/CreditsViewModel.kt | 65 ++++++ .../feature/credits/ui/PersonItem.kt | 139 +++++++++++++ .../credits/src/main/res/values/strings.xml | 3 + .../feature/details/ui/DetailsScreen.kt | 2 +- .../feature/watchlist/WatchlistScreen.kt | 19 +- 18 files changed, 538 insertions(+), 22 deletions(-) create mode 100644 core/domain/src/main/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCase.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTab.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTabs.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiState.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt create mode 100644 feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index dcce4bd0..0151dc29 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } dependencies { - implementation(projects.core.commons) + api(projects.core.commons) implementation(projects.core.data) implementation(projects.core.database) implementation(projects.core.datastore) diff --git a/core/domain/src/main/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCase.kt b/core/domain/src/main/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCase.kt new file mode 100644 index 00000000..26c81083 --- /dev/null +++ b/core/domain/src/main/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCase.kt @@ -0,0 +1,23 @@ +package com.divinelink.core.domain.credits + +import com.divinelink.core.commons.di.IoDispatcher +import com.divinelink.core.commons.domain.FlowUseCase +import com.divinelink.core.commons.domain.data +import com.divinelink.core.data.details.repository.DetailsRepository +import com.divinelink.core.model.credits.AggregateCredits +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class FetchCreditsUseCase @Inject constructor( + private val repository: DetailsRepository, + @IoDispatcher val dispatcher: CoroutineDispatcher, +) : FlowUseCase(dispatcher) { + + override fun execute(parameters: Long): Flow> = flow { + repository.fetchAggregateCredits(parameters).collect { + emit(Result.success(it.data)) + } + } +} diff --git a/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt b/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt index 98e4ab9f..797f7058 100644 --- a/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt +++ b/core/model/src/main/kotlin/com/divinelink/core/model/credits/SeriesCrewDepartment.kt @@ -7,4 +7,7 @@ import kotlinx.serialization.Serializable data class SeriesCrewDepartment( val department: String, val crewList: List, -) +) { + val uniqueCrewList: List + get() = crewList.distinctBy { it.id } +} diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt index 7eb7179f..feaeb9cd 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt @@ -73,4 +73,8 @@ object TestTags { const val WATCHLIST_SCREEN = "Watchlist Screen" const val WATCHLIST_ERROR_CONTENT = "Watchlist Error Content" } + + object Credits { + const val TAB_BAR = "Credits Tab Bar $%s" + } } diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt index b9226b9c..cefd82b6 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/scaffold/AppScaffold.kt @@ -1,6 +1,7 @@ package com.divinelink.core.ui.components.scaffold import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -27,6 +28,7 @@ fun AppScaffold( Scaffold( modifier = modifier + .fillMaxSize() .navigationBarsPadding() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { topBar(scrollBehavior, topAppBarColor) }, diff --git a/feature/credits/build.gradle.kts b/feature/credits/build.gradle.kts index 1d7d3ba3..8d86c5e3 100644 --- a/feature/credits/build.gradle.kts +++ b/feature/credits/build.gradle.kts @@ -16,6 +16,7 @@ ksp { dependencies { implementation(libs.kotlinx.serialization.json) + implementation(projects.core.domain) testImplementation(projects.core.testing) } diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt index 8082d4aa..40b57c90 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/navigation/CreditsNavArguments.kt @@ -1,9 +1,8 @@ package com.divinelink.feature.credits.navigation -import com.divinelink.core.model.credits.AggregateCredits import com.divinelink.core.model.media.MediaType data class CreditsNavArguments( + val id: Long, val mediaType: MediaType? = null, - val aggregateCredits: AggregateCredits? = null, ) diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt new file mode 100644 index 00000000..0f33e661 --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt @@ -0,0 +1,196 @@ +package com.divinelink.feature.credits.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import com.divinelink.core.designsystem.theme.AppTheme +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.credits.PersonRole +import com.divinelink.core.model.credits.SeriesCrewDepartment +import com.divinelink.core.model.details.Person +import com.divinelink.core.ui.Previews +import com.divinelink.core.ui.TestTags +import kotlinx.coroutines.launch + +@Composable +fun CreditsContent( + modifier: Modifier = Modifier, + state: CreditsUiState, + onTabSelected: (Int) -> Unit, + onPersonSelected: (Person) -> Unit, +) { + var selectedPage by rememberSaveable { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = selectedPage, + pageCount = { state.tabs.size }, + ) + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + onTabSelected(page) + selectedPage = page + } + } + + Column(modifier = modifier) { + CreditsTabs( + tabs = state.tabs, + selectedIndex = state.selectedTabIndex, + onClick = { + onTabSelected(it) + scope.launch { pagerState.animateScrollToPage(it) } + }, + ) + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + ) { page -> + when (val content = state.forms.values.elementAt(page)) { + is CreditsUiContent.Cast -> { + if (content.cast.isEmpty()) { + return@HorizontalPager + } + LazyColumn( + modifier = Modifier.testTag(TestTags.Watchlist.WATCHLIST_CONTENT), + contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + items( + items = content.cast, + key = { it.id }, + ) { person -> + PersonItem( + person = person, + onClick = onPersonSelected, + ) + } + } + } + is CreditsUiContent.Crew -> { + if (content.crew.isEmpty()) { + return@HorizontalPager + } + LazyColumn( + modifier = Modifier.testTag(TestTags.Watchlist.WATCHLIST_CONTENT), + contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + content.crew.forEach { department -> + item { + Text( + modifier = Modifier.padding( + top = MaterialTheme.dimensions.keyline_12, + bottom = MaterialTheme.dimensions.keyline_4, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + text = department.department, + ) + } + + items( + items = department.uniqueCrewList, + key = { + (it.role as PersonRole.Crew).creditId + (it.role as PersonRole.Crew).department + }, + ) { person -> + PersonItem( + person = person, + onClick = onPersonSelected, + ) + } + } + } + } + } + } + } +} + +@Previews +@Composable +private fun CreditsContentPreview() { + AppTheme { + CreditsContent( + state = CreditsUiState( + selectedTabIndex = 0, + tabs = listOf( + CreditsTab.Cast(2), + CreditsTab.Crew(1), + ), + forms = mapOf( + CreditsTab.Cast(2) to CreditsUiContent.Cast( + cast = listOf( + Person( + id = 1, + name = "Person 1", + profilePath = "https://image.tmdb.org/t/p/w185/1.jpg", + role = PersonRole.SeriesActor( + character = "Character 1", + ), + ), + Person( + id = 2, + name = "Person 2", + profilePath = "https://image.tmdb.org/t/p/w185/2.jpg", + role = PersonRole.SeriesActor( + character = "Character 2", + ), + ), + ), + ), + CreditsTab.Crew(1) to CreditsUiContent.Crew( + crew = listOf( + SeriesCrewDepartment( + department = "Department 1", + crewList = listOf( + Person( + id = 3, + name = "Person 3", + profilePath = "https://image.tmdb.org/t/p/w185/3.jpg", + role = PersonRole.Crew( + job = "Job 3", + creditId = "Credit 3", + ), + ), + Person( + id = 4, + name = "Person 4", + profilePath = "https://image.tmdb.org/t/p/w185/4.jpg", + role = PersonRole.Crew( + job = "Job 4", + creditId = "Credit 4", + ), + ), + ), + ), + ), + ), + ), + ), + onTabSelected = {}, + onPersonSelected = { }, + ) + } +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt index c7255e85..ecdb6a89 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt @@ -1,5 +1,6 @@ package com.divinelink.feature.credits.ui +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api @@ -9,8 +10,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.navigation.compose.hiltViewModel import com.divinelink.core.ui.components.scaffold.AppScaffold import com.divinelink.feature.credits.R import com.divinelink.feature.credits.navigation.CreditsGraph @@ -26,7 +30,12 @@ import com.divinelink.core.ui.R as uiR start = true, navArgs = CreditsNavArguments::class, ) -fun CreditsScreen(navigator: DestinationsNavigator) { +fun CreditsScreen( + navigator: DestinationsNavigator, + viewModel: CreditsViewModel = hiltViewModel(), +) { + val uiState = viewModel.uiState.collectAsState().value + AppScaffold( topBar = { scrollBehaviour, color -> TopAppBar( @@ -42,7 +51,7 @@ fun CreditsScreen(navigator: DestinationsNavigator) { }, navigationIcon = { IconButton( - onClick = { navigator.navigateUp() }, + onClick = navigator::navigateUp, ) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, @@ -54,5 +63,13 @@ fun CreditsScreen(navigator: DestinationsNavigator) { ) }, ) { paddingValues -> + CreditsContent( + modifier = Modifier.padding(paddingValues), + state = uiState, + onTabSelected = viewModel::onTabSelected, + onPersonSelected = { + // TODO navigate to person details + }, + ) } } diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTab.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTab.kt new file mode 100644 index 00000000..65405b24 --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTab.kt @@ -0,0 +1,12 @@ +package com.divinelink.feature.credits.ui + +import androidx.annotation.StringRes +import com.divinelink.feature.credits.R + +sealed class CreditsTab( + @StringRes val titleRes: Int, + open var size: Int, +) { + data class Cast(override var size: Int) : CreditsTab(R.string.feature_credits_cast, size) + data class Crew(override var size: Int) : CreditsTab(R.string.feature_credits_crew, size) +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTabs.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTabs.kt new file mode 100644 index 00000000..b1a72b33 --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsTabs.kt @@ -0,0 +1,33 @@ +package com.divinelink.feature.credits.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import com.divinelink.core.ui.TestTags + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreditsTabs( + tabs: List, + selectedIndex: Int, + onClick: (Int) -> Unit, +) { + Row { + SecondaryTabRow(selectedTabIndex = selectedIndex) { + tabs.forEachIndexed { index, tab -> + Tab( + modifier = Modifier.testTag(TestTags.Credits.TAB_BAR.format(tab)), + text = { Text(stringResource(tab.titleRes, tab.size)) }, + selected = index == selectedIndex, + onClick = { onClick(index) }, + ) + } + } + } +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt new file mode 100644 index 00000000..a3db49ef --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt @@ -0,0 +1,9 @@ +package com.divinelink.feature.credits.ui + +import com.divinelink.core.model.credits.SeriesCrewDepartment +import com.divinelink.core.model.details.Person + +sealed interface CreditsUiContent { + data class Cast(val cast: List) : CreditsUiContent + data class Crew(val crew: List) : CreditsUiContent +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiState.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiState.kt new file mode 100644 index 00000000..ea371e58 --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiState.kt @@ -0,0 +1,21 @@ +package com.divinelink.feature.credits.ui + +data class CreditsUiState( + val selectedTabIndex: Int, + val tabs: List, + val forms: Map, +) { + companion object { + fun initial(): CreditsUiState = CreditsUiState( + selectedTabIndex = 0, + tabs = listOf( + CreditsTab.Cast(0), + CreditsTab.Crew(0), + ), + forms = mapOf( + CreditsTab.Cast(0) to CreditsUiContent.Cast(emptyList()), + CreditsTab.Crew(0) to CreditsUiContent.Crew(emptyList()), + ), + ) + } +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt new file mode 100644 index 00000000..2dd2e2d4 --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt @@ -0,0 +1,65 @@ +package com.divinelink.feature.credits.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.divinelink.core.domain.credits.FetchCreditsUseCase +import com.divinelink.feature.credits.navigation.CreditsNavArguments +import com.divinelink.feature.credits.screens.destinations.CreditsScreenDestination +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class CreditsViewModel @Inject constructor( + fetchCreditsUseCase: FetchCreditsUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val args: CreditsNavArguments = CreditsScreenDestination.argsFrom(savedStateHandle) + + private val _uiState: MutableStateFlow = MutableStateFlow( + CreditsUiState.initial(), + ) + + val uiState: StateFlow = _uiState + + init { + fetchCreditsUseCase(args.id) + .onEach { result -> + println("result: $result") + result.onSuccess { credits -> + _uiState.update { + it.copy( + forms = mapOf( + CreditsTab.Cast(credits.cast.size) to CreditsUiContent.Cast(credits.cast), + CreditsTab.Crew( + credits.crewDepartments.sumOf { department -> department.uniqueCrewList.size }, + ) to CreditsUiContent.Crew(credits.crewDepartments.sortedBy { it.department }), + ), + tabs = listOf( + CreditsTab.Cast(credits.cast.size), + CreditsTab.Crew( + credits.crewDepartments + .sumOf { department -> + department.crewList.distinctBy { crew -> crew.id }.size + }, + ), + ), + ) + } + } + } + .launchIn(viewModelScope) + } + + fun onTabSelected(tabIndex: Int) { + _uiState.update { + it.copy(selectedTabIndex = tabIndex) + } + } +} diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt new file mode 100644 index 00000000..18bae3aa --- /dev/null +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt @@ -0,0 +1,139 @@ +package com.divinelink.feature.credits.ui + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.divinelink.core.designsystem.theme.AppTheme +import com.divinelink.core.designsystem.theme.dimensions +import com.divinelink.core.model.credits.PersonRole +import com.divinelink.core.model.details.Person +import com.divinelink.core.ui.MovieImage +import com.divinelink.core.ui.Previews +import com.divinelink.feature.credits.R + +@Composable +fun PersonItem( + modifier: Modifier = Modifier, + person: Person, + onClick: (Person) -> Unit, +) { + Card( + modifier = modifier, + onClick = { onClick(person) }, + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + ) { + Row( + modifier = Modifier + .padding(MaterialTheme.dimensions.keyline_8) + .heightIn(max = 120.dp) + .wrapContentSize() + .fillMaxWidth(), + ) { + MovieImage( + path = person.profilePath, + errorPlaceHolder = painterResource( + id = com.divinelink.core.ui.R.drawable.core_ui_ic_person_placeholder, + ), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(start = MaterialTheme.dimensions.keyline_12), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_8), + ) { + Spacer(Modifier.weight(1f)) + Text( + text = person.name, + style = MaterialTheme.typography.titleMedium, + ) + when (person.role) { + PersonRole.Creator -> TODO() + is PersonRole.Crew -> { + Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4)) { + Text( + modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), + text = (person.role as PersonRole.Crew).job!!, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), + text = stringResource( + R.string.feature_credits_character_total_episodes, + (person.role as PersonRole.Crew).totalEpisodes!!, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.80f), + ) + } + } + PersonRole.Director -> TODO() + is PersonRole.MovieActor -> TODO() + is PersonRole.SeriesActor -> { + Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4)) { + Text( + modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), + text = (person.role as PersonRole.SeriesActor).character!!, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), + text = stringResource( + R.string.feature_credits_character_total_episodes, + (person.role as PersonRole.SeriesActor).totalEpisodes!!, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.80f), + ) + } + } + PersonRole.Unknown -> TODO() + } + Spacer(Modifier.weight(1f)) + } + } + } +} + +@Previews +@Composable +private fun PersonItemPreview() { + AppTheme { + Surface { + PersonItem( + person = Person( + id = 1, + name = "Person 1", + profilePath = "https://image.tmdb.org/t/p/w185/1.jpg", + role = PersonRole.SeriesActor( + character = "Character 1", + totalEpisodes = 10, + ), + ), + onClick = {}, + ) + } + } +} diff --git a/feature/credits/src/main/res/values/strings.xml b/feature/credits/src/main/res/values/strings.xml index 3cd9a5a5..f8c56faa 100644 --- a/feature/credits/src/main/res/values/strings.xml +++ b/feature/credits/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ Cast & Crew + Cast (%s) + Crew (%s) + (%s Episodes) \ No newline at end of file diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt index ef4a2b34..67c1e6ff 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsScreen.kt @@ -106,7 +106,7 @@ fun DetailsScreen( CreditsScreenDestination( CreditsNavArguments( mediaType = viewState.value.mediaType, - aggregateCredits = viewState.value.tvCredits!!, + id = viewState.value.tvCredits?.id!!, // TODO fix this ), ), ) diff --git a/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt b/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt index 655dbb1a..1981b81b 100644 --- a/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt +++ b/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt @@ -7,15 +7,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -26,12 +22,12 @@ 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.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.components.LoadingContent +import com.divinelink.core.ui.components.scaffold.AppScaffold import com.divinelink.feature.details.screens.destinations.DetailsScreenDestination import com.divinelink.feature.settings.screens.destinations.AccountSettingsScreenDestination import com.divinelink.feature.watchlist.navigation.WatchlistGraph @@ -46,8 +42,6 @@ internal fun WatchlistScreen( navigator: DestinationsNavigator, viewModel: WatchlistViewModel = hiltViewModel(), ) { - val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) - var selectedPage by rememberSaveable { mutableIntStateOf(0) } val uiState = viewModel.uiState.collectAsState() @@ -64,15 +58,10 @@ internal fun WatchlistScreen( } } - Scaffold( - modifier = Modifier - .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { + AppScaffold( + topBar = { scrollBehavior, topAppBarColors -> TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.surface, - ), + colors = topAppBarColors, scrollBehavior = scrollBehavior, title = { Text(stringResource(R.string.feature_watchlist_title)) From 73edf234923b074eb299d77311719a9e2f0f8143 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 21 Jul 2024 11:48:13 +0300 Subject: [PATCH 03/10] fix: insert crew and cast concurrently --- .../details/repository/ProdDetailsRepository.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/core/data/src/main/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt b/core/data/src/main/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt index 0bfa4e65..67b6a1aa 100644 --- a/core/data/src/main/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt +++ b/core/data/src/main/kotlin/com/divinelink/core/data/details/repository/ProdDetailsRepository.kt @@ -117,14 +117,17 @@ class ProdDetailsRepository @Inject constructor( override fun fetchAggregateCredits(id: Long): Flow> = flow { val localExists = creditsDao.checkIfAggregateCreditsExist(id).first() - val result = if (localExists) { + if (localExists) { Timber.d("Fetching local credits") - fetchLocalAggregateCredits(id).first() + fetchLocalAggregateCredits(id).collect { + emit(it) + } } else { Timber.d("Fetching remote credits") - fetchRemoteAggregateCredits(id).first() + fetchRemoteAggregateCredits(id).collect { + emit(it) + } } - emit(result) }.flowOn(dispatcher) private fun insertLocalAggregateCredits(aggregateCredits: AggregateCreditsApi) { @@ -132,8 +135,14 @@ class ProdDetailsRepository @Inject constructor( CoroutineScope(scope.coroutineContext + dispatcher).launch { creditsDao.insertCastRoles(aggregateCredits.toSeriesCastRoleEntity()) + } + CoroutineScope(scope.coroutineContext + dispatcher).launch { creditsDao.insertCast(aggregateCredits.toSeriesCastEntity()) + } + CoroutineScope(scope.coroutineContext + dispatcher).launch { creditsDao.insertCrewJobs(aggregateCredits.toSeriesCrewJobEntity()) + } + CoroutineScope(scope.coroutineContext + dispatcher).launch { creditsDao.insertCrew(aggregateCredits.toSeriesCrewEntity()) } } From 7906299a912b838f286e8ca9cb91cb02472c0161 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 21 Jul 2024 12:15:17 +0300 Subject: [PATCH 04/10] feat: add crew sorting on db query --- .../repository/ProdDetailsRepositoryTest.kt | 3 +- .../ui/details/DetailsContentTest.kt | 14 +++++ .../core/database/credits/crew/SeriesCrew.sq | 3 +- .../credits/crew/SeriesCrewFactory.kt | 4 +- .../credits/crew/SeriesCrewJobFactory.kt | 4 +- .../credits/AggregatedCreditsFactory.kt | 62 ++++++++++++++++++- .../entity/credits/CrewEntityFactory.kt | 4 +- .../kotlin/com/divinelink/core/ui/TestTags.kt | 1 + .../feature/credits/ui/CreditsContent.kt | 4 +- .../feature/credits/ui/CreditsViewModel.kt | 3 +- 10 files changed, 88 insertions(+), 14 deletions(-) diff --git a/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt b/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt index 3e9e159a..9949b63d 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt @@ -451,7 +451,7 @@ class ProdDetailsRepositoryTest { repository.fetchAggregateCredits(1).test { val result = awaitItem() assertThat(result.isSuccess).isTrue() - assertThat(result.data).isEqualTo(AggregatedCreditsFactory.partialCredits()) + assertThat(result.data).isEqualTo(AggregatedCreditsFactory.unsortedCredits()) awaitComplete() } } @@ -489,7 +489,6 @@ class ProdDetailsRepositoryTest { val result = awaitItem() assertThat(result.isSuccess).isTrue() assertThat(result.data).isEqualTo(AggregatedCreditsFactory.partialCredits()) - awaitComplete() } } } diff --git a/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt b/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt index da7d4ed6..5618b867 100644 --- a/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt +++ b/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt @@ -53,6 +53,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -82,6 +83,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -106,6 +108,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -131,6 +134,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -169,6 +173,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -211,6 +216,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -236,6 +242,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -269,6 +276,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -299,6 +307,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -333,6 +342,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -377,6 +387,7 @@ class DetailsContentTest : ComposeTest() { ) }, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -421,6 +432,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -448,6 +460,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } @@ -479,6 +492,7 @@ class DetailsContentTest : ComposeTest() { onAddRateClicked = {}, onAddToWatchlistClicked = {}, requestMedia = {}, + viewAllCreditsClicked = {}, ) } diff --git a/core/database/src/main/sqldelight/com/divinelink/core/database/credits/crew/SeriesCrew.sq b/core/database/src/main/sqldelight/com/divinelink/core/database/credits/crew/SeriesCrew.sq index 820a4e46..2ec8c792 100644 --- a/core/database/src/main/sqldelight/com/divinelink/core/database/credits/crew/SeriesCrew.sq +++ b/core/database/src/main/sqldelight/com/divinelink/core/database/credits/crew/SeriesCrew.sq @@ -47,4 +47,5 @@ FROM seriesCrewWithJob WHERE aggregateCreditId = ? AND jobTitle IS NOT NULL AND jobCreditId IS NOT NULL -AND jobEpisodeCount > 0; +AND jobEpisodeCount > 0 +ORDER BY crewDepartment ASC, crewName ASC; diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewFactory.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewFactory.kt index 113a81f2..233ca946 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewFactory.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewFactory.kt @@ -50,9 +50,9 @@ object SeriesCrewFactory { ) fun cameraDepartment() = listOf( - randallEinhorn(), daleAlexander(), - ronNichols(), + randallEinhorn(), peterSmokler(), + ronNichols(), ) } diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewJobFactory.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewJobFactory.kt index fe083582..b44ce771 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewJobFactory.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/database/credits/crew/SeriesCrewJobFactory.kt @@ -38,9 +38,9 @@ object SeriesCrewJobFactory { ) fun allCrewJobs() = listOf( - randallEinhorn(), daleAlexander(), - ronNichols(), peterSmokler(), + randallEinhorn(), + ronNichols(), ) } diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt index d2e59146..cdb0aecf 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt @@ -13,6 +13,16 @@ object AggregatedCreditsFactory { crewDepartments = SeriesCrewListFactory.crewDepartments(), ) + // Data fetched from the API is unsorted + fun unsortedCredits() = AggregateCredits( + id = 2316, + cast = SeriesCastFactory.cast().take(2), + crewDepartments = listOf( + SeriesCrewListFactory.unsortedCameraDepartment(), + ), + ) + + // Data fetched from the database is sorted fun partialCredits() = AggregateCredits( id = 2316, cast = SeriesCastFactory.cast().take(2), @@ -180,7 +190,7 @@ object SeriesCrewListFactory { ), ) - fun camera() = SeriesCrewDepartment( + fun unsortedCameraDepartment() = SeriesCrewDepartment( department = "Camera", crewList = listOf( Person( @@ -230,6 +240,56 @@ object SeriesCrewListFactory { ), ) + fun camera() = SeriesCrewDepartment( + department = "Camera", + crewList = listOf( + Person( + id = 1879373, + name = "Dale Alexander", + profilePath = null, + role = PersonRole.Crew( + job = "Key Grip", + creditId = "5bdaa7d90e0a2603c60086d9", + totalEpisodes = 3, + department = "Camera", + ), + ), + Person( + id = 67864, + name = "Peter Smokler", + profilePath = null, + role = PersonRole.Crew( + job = "Director of Photography", + creditId = "5bdaa2d4c3a368078f007f5c", + totalEpisodes = 1, + department = "Camera", + ), + ), + Person( + id = 1215572, + name = "Randall Einhorn", + profilePath = null, + role = PersonRole.Crew( + job = "Director of Photography", + creditId = "5bdaa68f92514153f500859f", + totalEpisodes = 3, + department = "Camera", + ), + ), + Person( + id = 2166021, + name = "Ron Nichols", + profilePath = null, + role = PersonRole.Crew( + job = "Key Grip", + creditId = "5bdaa3e40e0a2603b1008d3f", + totalEpisodes = 1, + department = "Camera", + ), + ), + ), + ) + fun costumeAndMakeUp() = SeriesCrewDepartment( department = "Costume & Make-Up", crewList = listOf( diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/entity/credits/CrewEntityFactory.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/entity/credits/CrewEntityFactory.kt index 8076df71..802e0c4f 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/entity/credits/CrewEntityFactory.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/entity/credits/CrewEntityFactory.kt @@ -54,9 +54,9 @@ object CrewEntityFactory { ) fun cameraDepartment() = listOf( - randallEinhorn(), daleAlexander(), - ronNichols(), peterSmokler(), + randallEinhorn(), + ronNichols(), ) } diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt index feaeb9cd..4ab27d0e 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt @@ -76,5 +76,6 @@ object TestTags { object Credits { const val TAB_BAR = "Credits Tab Bar $%s" + const val CREDITS_CONTENT = "Credits Content with data" } } diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt index 0f33e661..442a8c03 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt @@ -72,7 +72,7 @@ fun CreditsContent( return@HorizontalPager } LazyColumn( - modifier = Modifier.testTag(TestTags.Watchlist.WATCHLIST_CONTENT), + modifier = Modifier.testTag(TestTags.Credits.CREDITS_CONTENT), contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), ) { @@ -92,7 +92,7 @@ fun CreditsContent( return@HorizontalPager } LazyColumn( - modifier = Modifier.testTag(TestTags.Watchlist.WATCHLIST_CONTENT), + modifier = Modifier.testTag(TestTags.Credits.CREDITS_CONTENT), contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), ) { diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt index 2dd2e2d4..965b3d09 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsViewModel.kt @@ -31,7 +31,6 @@ class CreditsViewModel @Inject constructor( init { fetchCreditsUseCase(args.id) .onEach { result -> - println("result: $result") result.onSuccess { credits -> _uiState.update { it.copy( @@ -39,7 +38,7 @@ class CreditsViewModel @Inject constructor( CreditsTab.Cast(credits.cast.size) to CreditsUiContent.Cast(credits.cast), CreditsTab.Crew( credits.crewDepartments.sumOf { department -> department.uniqueCrewList.size }, - ) to CreditsUiContent.Crew(credits.crewDepartments.sortedBy { it.department }), + ) to CreditsUiContent.Crew(credits.crewDepartments), ), tabs = listOf( CreditsTab.Cast(credits.cast.size), From 0895c84ed033b6d1bd5b26b13f3c47252e0eb446 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Sun, 21 Jul 2024 12:30:18 +0300 Subject: [PATCH 05/10] fix: temporarily remove view all credits for movies --- .../core/ui/components/details/cast/CastList.kt | 16 +++++++++++----- .../feature/details/ui/DetailsContent.kt | 1 + 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt index 48eb60ce..8ef0cc2c 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CastList.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -29,6 +30,7 @@ import com.divinelink.core.ui.R fun CastList( cast: List, onViewAllClick: () -> Unit, + viewAllVisible: Boolean = true, ) { Column( modifier = Modifier @@ -49,11 +51,15 @@ fun CastList( text = stringResource(id = R.string.details__cast_title), ) Spacer(modifier = Modifier.weight(1f)) - TextButton( - modifier = Modifier.align(alignment = Alignment.CenterVertically), - onClick = onViewAllClick, - ) { - Text(stringResource(id = R.string.core_ui_view_all)) + if (viewAllVisible) { + TextButton( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + onClick = onViewAllClick, + ) { + Text(stringResource(id = R.string.core_ui_view_all)) + } + } else { + Spacer(modifier = Modifier.height(MaterialTheme.dimensions.keyline_40)) } } diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt index 513be78b..d285a238 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsContent.kt @@ -349,6 +349,7 @@ fun MediaDetailsContent( CastList( cast = mediaDetails.cast, onViewAllClick = viewAllCreditsClicked, + viewAllVisible = false, ) mediaDetails.director?.let { DirectorItem(director = it) From cbf300c8c88869f13eb55a94d39ab00abecb4fdd Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Mon, 22 Jul 2024 00:22:01 +0300 Subject: [PATCH 06/10] fix: get account details and menu items from details use case --- .../feature/details/ui/DetailsViewModel.kt | 151 ++++++++---------- .../feature/details/ui/MovieDetailsResult.kt | 6 + .../FetchAccountMediaDetailsUseCase.kt | 11 +- .../details/usecase/GetMovieDetailsUseCase.kt | 29 ++++ 4 files changed, 103 insertions(+), 94 deletions(-) diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt index 2f481245..93c00596 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt @@ -7,10 +7,8 @@ import androidx.lifecycle.viewModelScope import com.divinelink.core.commons.ErrorHandler import com.divinelink.core.commons.domain.data import com.divinelink.core.data.details.model.MediaDetailsException -import com.divinelink.core.data.details.model.MediaDetailsParams import com.divinelink.core.data.jellyseerr.model.JellyseerrRequestParams import com.divinelink.core.data.session.model.SessionException -import com.divinelink.core.domain.GetDropdownMenuItemsUseCase import com.divinelink.core.domain.MarkAsFavoriteUseCase import com.divinelink.core.domain.jellyseerr.RequestMediaUseCase import com.divinelink.core.model.account.AccountMediaDetails @@ -24,7 +22,6 @@ import com.divinelink.feature.details.usecase.AddToWatchlistParameters import com.divinelink.feature.details.usecase.AddToWatchlistUseCase import com.divinelink.feature.details.usecase.DeleteRatingParameters import com.divinelink.feature.details.usecase.DeleteRatingUseCase -import com.divinelink.feature.details.usecase.FetchAccountMediaDetailsUseCase import com.divinelink.feature.details.usecase.GetMovieDetailsUseCase import com.divinelink.feature.details.usecase.SubmitRatingParameters import com.divinelink.feature.details.usecase.SubmitRatingUseCase @@ -47,12 +44,10 @@ import com.divinelink.core.ui.R as uiR class DetailsViewModel @Inject constructor( getMovieDetailsUseCase: GetMovieDetailsUseCase, private val onMarkAsFavoriteUseCase: MarkAsFavoriteUseCase, - private val fetchAccountMediaDetailsUseCase: FetchAccountMediaDetailsUseCase, private val submitRatingUseCase: SubmitRatingUseCase, private val deleteRatingUseCase: DeleteRatingUseCase, private val addToWatchlistUseCase: AddToWatchlistUseCase, private val requestMediaUseCase: RequestMediaUseCase, - private val getMenuItemsUseCase: GetDropdownMenuItemsUseCase, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -90,68 +85,79 @@ class DetailsViewModel @Inject constructor( MediaType.PERSON -> DetailsRequestApi.Unknown MediaType.UNKNOWN -> DetailsRequestApi.Unknown } - getMovieDetailsUseCase( - parameters = requestApi, - ).onEach { result -> - result.onSuccess { - _viewState.update { viewState -> - when (result.data) { - is MovieDetailsResult.DetailsSuccess -> { - viewState.copy( - isLoading = false, - mediaDetails = (result.data as MovieDetailsResult.DetailsSuccess).mediaDetails, + + getMovieDetailsUseCase(parameters = requestApi) + .onEach { result -> + result.onSuccess { + _viewState.update { viewState -> + when (result.data) { + is MovieDetailsResult.DetailsSuccess -> { + viewState.copy( + isLoading = false, + mediaDetails = (result.data as MovieDetailsResult.DetailsSuccess).mediaDetails, + ) + } + + is MovieDetailsResult.ReviewsSuccess -> viewState.copy( + reviews = (result.data as MovieDetailsResult.ReviewsSuccess).reviews, ) - } - is MovieDetailsResult.ReviewsSuccess -> viewState.copy( - reviews = (result.data as MovieDetailsResult.ReviewsSuccess).reviews, - ) + is MovieDetailsResult.SimilarSuccess -> viewState.copy( + similarMovies = (result.data as MovieDetailsResult.SimilarSuccess).similar, + ) - is MovieDetailsResult.SimilarSuccess -> viewState.copy( - similarMovies = (result.data as MovieDetailsResult.SimilarSuccess).similar, - ) + is MovieDetailsResult.VideosSuccess -> viewState.copy( + trailer = (result.data as MovieDetailsResult.VideosSuccess).trailer, + ) - is MovieDetailsResult.VideosSuccess -> viewState.copy( - trailer = (result.data as MovieDetailsResult.VideosSuccess).trailer, - ) + is MovieDetailsResult.CreditsSuccess -> { + val credits = (result.data as MovieDetailsResult.CreditsSuccess).aggregateCredits + viewState.copy(tvCredits = credits) + } - is MovieDetailsResult.CreditsSuccess -> { - val credits = (result.data as MovieDetailsResult.CreditsSuccess).aggregateCredits - viewState.copy(tvCredits = credits) - } + is MovieDetailsResult.AccountDetailsSuccess -> { + val successData = (result.data as MovieDetailsResult.AccountDetailsSuccess) + viewState.copy( + userDetails = successData.accountDetails, + ) + } - is MovieDetailsResult.Failure.FatalError -> viewState.copy( - error = (result.data as MovieDetailsResult.Failure.FatalError).message, - isLoading = false, - ) + is MovieDetailsResult.MenuOptionsSuccess -> { + val successData = (result.data as MovieDetailsResult.MenuOptionsSuccess) + viewState.copy( + menuOptions = successData.menuOptions, + ) + } - MovieDetailsResult.Failure.Unknown -> viewState.copy( - error = MovieDetailsResult.Failure.Unknown.message, - isLoading = false, - ) - } - } - }.onFailure { - if (it is MediaDetailsException) { - _viewState.update { viewState -> - viewState.copy( - error = MovieDetailsResult.Failure.FatalError().message, - isLoading = false, - ) + is MovieDetailsResult.Failure.FatalError -> viewState.copy( + error = (result.data as MovieDetailsResult.Failure.FatalError).message, + isLoading = false, + ) + + MovieDetailsResult.Failure.Unknown -> viewState.copy( + error = MovieDetailsResult.Failure.Unknown.message, + isLoading = false, + ) + } } - } else { - _viewState.update { viewState -> - viewState.copy( - error = MovieDetailsResult.Failure.Unknown.message, - isLoading = false, - ) + }.onFailure { + if (it is MediaDetailsException) { + _viewState.update { viewState -> + viewState.copy( + error = MovieDetailsResult.Failure.FatalError().message, + isLoading = false, + ) + } + } else { + _viewState.update { viewState -> + viewState.copy( + error = MovieDetailsResult.Failure.Unknown.message, + isLoading = false, + ) + } } } - } - }.onCompletion { - fetchAccountMediaDetails() - getMenuItems() - }.launchIn(viewModelScope) + }.launchIn(viewModelScope) } fun onSubmitRate(rating: Int) { @@ -369,37 +375,6 @@ class DetailsViewModel @Inject constructor( } } - private suspend fun getMenuItems() { - getMenuItemsUseCase(Unit) - .collectLatest { result -> - result.onSuccess { - _viewState.update { viewState -> - viewState.copy( - menuOptions = result.data, - ) - } - } - } - } - - private suspend fun fetchAccountMediaDetails() { - val params = MediaDetailsParams( - id = viewState.value.mediaId, - mediaType = viewState.value.mediaType, - ) - - fetchAccountMediaDetailsUseCase.invoke(params) - .collectLatest { result -> - result.onSuccess { - _viewState.update { viewState -> - viewState.copy( - userDetails = result.data, - ) - } - } - } - } - private fun setSnackbarMessage(text: UIText) { _viewState.update { viewState -> viewState.copy(snackbarMessage = SnackbarMessage.from(text)) diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt index 34349471..dab69539 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt @@ -1,6 +1,8 @@ package com.divinelink.feature.details.ui +import com.divinelink.core.model.account.AccountMediaDetails import com.divinelink.core.model.credits.AggregateCredits +import com.divinelink.core.model.details.DetailsMenuOptions import com.divinelink.core.model.details.MediaDetails import com.divinelink.core.model.details.Review import com.divinelink.core.model.details.video.Video @@ -12,6 +14,8 @@ import com.divinelink.feature.details.R * A collection of possible results for an attempt to fetch movie details, similar movies and reviews. */ sealed class MovieDetailsResult { + data class AccountDetailsSuccess(val accountDetails: AccountMediaDetails) : MovieDetailsResult() + data class DetailsSuccess(val mediaDetails: MediaDetails) : MovieDetailsResult() data class ReviewsSuccess(val reviews: List) : MovieDetailsResult() @@ -22,6 +26,8 @@ sealed class MovieDetailsResult { data class CreditsSuccess(val aggregateCredits: AggregateCredits) : MovieDetailsResult() + data class MenuOptionsSuccess(val menuOptions: List) : MovieDetailsResult() + sealed class Failure( open val message: UIText = UIText.ResourceText(R.string.general_error_message), ) : MovieDetailsResult() { diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/FetchAccountMediaDetailsUseCase.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/FetchAccountMediaDetailsUseCase.kt index 45c36e0f..1ab9efb3 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/FetchAccountMediaDetailsUseCase.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/FetchAccountMediaDetailsUseCase.kt @@ -3,17 +3,16 @@ package com.divinelink.feature.details.usecase import com.divinelink.core.commons.di.IoDispatcher import com.divinelink.core.commons.domain.FlowUseCase import com.divinelink.core.commons.domain.data +import com.divinelink.core.data.details.model.MediaDetailsParams import com.divinelink.core.data.details.repository.DetailsRepository import com.divinelink.core.data.session.model.SessionException import com.divinelink.core.datastore.SessionStorage import com.divinelink.core.model.account.AccountMediaDetails -import com.divinelink.core.data.details.model.MediaDetailsParams import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.media.model.states.AccountMediaDetailsRequestApi import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.last import javax.inject.Inject open class FetchAccountMediaDetailsUseCase @Inject constructor( @@ -40,11 +39,11 @@ open class FetchAccountMediaDetailsUseCase @Inject constructor( else -> throw IllegalArgumentException("Unsupported media type: ${parameters.mediaType}") } - val response = repository.fetchAccountMediaDetails( + repository.fetchAccountMediaDetails( request = request, - ).last() - - emit(Result.success(response.data)) + ).collect { + emit(Result.success(it.data)) + } } } } diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt index 8bf07a3d..632353ad 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt @@ -5,8 +5,10 @@ import com.divinelink.core.commons.domain.FlowUseCase import com.divinelink.core.commons.domain.data import com.divinelink.core.data.details.model.InvalidMediaTypeException import com.divinelink.core.data.details.model.MediaDetailsException +import com.divinelink.core.data.details.model.MediaDetailsParams import com.divinelink.core.data.details.repository.DetailsRepository import com.divinelink.core.data.media.repository.MediaRepository +import com.divinelink.core.domain.GetDropdownMenuItemsUseCase import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.media.model.details.DetailsRequestApi import com.divinelink.core.network.media.model.details.similar.SimilarRequestApi @@ -23,6 +25,8 @@ import javax.inject.Inject open class GetMovieDetailsUseCase @Inject constructor( private val repository: DetailsRepository, private val mediaRepository: MediaRepository, + private val fetchAccountMediaDetailsUseCase: FetchAccountMediaDetailsUseCase, + private val getMenuItemsUseCase: GetDropdownMenuItemsUseCase, @IoDispatcher val dispatcher: CoroutineDispatcher, ) : FlowUseCase(dispatcher) { override fun execute(parameters: DetailsRequestApi): Flow> = @@ -110,5 +114,30 @@ open class GetMovieDetailsUseCase @Inject constructor( send(Result.success(MovieDetailsResult.VideosSuccess(video))) } } + + launch(dispatcher) { + fetchAccountMediaDetailsUseCase( + MediaDetailsParams( + id = requestApi.id, + mediaType = MediaType.from(requestApi.endpoint), + ), + ) + .catch { Timber.e(it) } + .collect { result -> + result.onSuccess { + send(Result.success(MovieDetailsResult.AccountDetailsSuccess(result.data))) + } + } + } + + launch(dispatcher) { + getMenuItemsUseCase(Unit) + .catch { Timber.e(it) } + .collect { result -> + result.onSuccess { + send(Result.success(MovieDetailsResult.MenuOptionsSuccess(result.data))) + } + } + } } } From d6de4effc4f4a5bf76fb95ec565a1252da6e6cf4 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Mon, 22 Jul 2024 00:42:22 +0300 Subject: [PATCH 07/10] feat: update person role subheader to be multiline --- .../feature/credits/ui/PersonItem.kt | 73 +++++++++++++------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt index 18bae3aa..c1ee17ce 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/PersonItem.kt @@ -19,6 +19,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.divinelink.core.designsystem.theme.AppTheme import com.divinelink.core.designsystem.theme.dimensions @@ -69,21 +72,14 @@ fun PersonItem( PersonRole.Creator -> TODO() is PersonRole.Crew -> { Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4)) { - Text( - modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), - text = (person.role as PersonRole.Crew).job!!, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + val job = (person.role as PersonRole.Crew).job + val totalEpisodes = (person.role as PersonRole.Crew).totalEpisodes?.toInt() + + val jobText = buildPersonSubHeader(mapOf(job to totalEpisodes)) Text( modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), - text = stringResource( - R.string.feature_credits_character_total_episodes, - (person.role as PersonRole.Crew).totalEpisodes!!, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.80f), + text = jobText, ) } } @@ -91,21 +87,14 @@ fun PersonItem( is PersonRole.MovieActor -> TODO() is PersonRole.SeriesActor -> { Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4)) { - Text( - modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), - text = (person.role as PersonRole.SeriesActor).character!!, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - ) + val character = (person.role as PersonRole.SeriesActor).character + val totalEpisodes = (person.role as PersonRole.SeriesActor).totalEpisodes + + val characterText = buildPersonSubHeader(mapOf(character to totalEpisodes)) Text( modifier = Modifier.padding(top = MaterialTheme.dimensions.keyline_4), - text = stringResource( - R.string.feature_credits_character_total_episodes, - (person.role as PersonRole.SeriesActor).totalEpisodes!!, - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.80f), + text = characterText, ) } } @@ -117,6 +106,42 @@ fun PersonItem( } } +/** + * Builds the text for the character and total episodes. + * @param roles The role and total episodes. + * Can be a character if the person is an actor or a job if the person is a crew member. + */ +@Composable +private fun buildPersonSubHeader(roles: Map): AnnotatedString = + buildAnnotatedString { + roles.forEach { (role, totalEpisodes) -> + withStyle(MaterialTheme.typography.labelMedium.toSpanStyle()) { + append(role) + } + + totalEpisodes?.let { + append(" ") + withStyle( + MaterialTheme.typography.labelMedium.toSpanStyle().copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.80f), + ), + ) { + append( + stringResource( + R.string.feature_credits_character_total_episodes, + totalEpisodes, + ), + ) + } + } + + // Add a comma if there are more roles to display + if (roles.size > 1 && role != roles.keys.last()) { + append(", ") + } + } + } + @Previews @Composable private fun PersonItemPreview() { From a89168fedd5e17cbcb33cda82575b48cf2ee0cc2 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Mon, 22 Jul 2024 18:34:52 +0300 Subject: [PATCH 08/10] test: add tests for media details fetch use case updates --- ...eTest.kt => GetMediaDetailsUseCaseTest.kt} | 227 +++++++++-- .../details/ui/DetailsViewModelRobot.kt | 25 +- .../details/ui/DetailsViewModelTest.kt | 365 ++++++++---------- .../usecase/FakeGetMediaDetailsUseCase.kt | 17 + .../usecase/FakeGetMoviesDetailsUseCase.kt | 17 - .../FakeFetchAccountMediaDetailsUseCase.kt | 3 +- .../andreolas/ui/details/DetailsScreenTest.kt | 49 +-- .../credits/AggregatedCreditsFactory.kt | 54 ++- .../FakeGetDropdownMenuItemsUseCase.kt | 2 +- .../feature/details/ui/DetailsViewModel.kt | 46 +-- ...DetailsResult.kt => MediaDetailsResult.kt} | 18 +- ...lsUseCase.kt => GetMediaDetailsUseCase.kt} | 22 +- 12 files changed, 501 insertions(+), 344 deletions(-) rename app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/{GetMoviesDetailsUseCaseTest.kt => GetMediaDetailsUseCaseTest.kt} (58%) create mode 100644 app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMediaDetailsUseCase.kt delete mode 100644 app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMoviesDetailsUseCase.kt rename feature/details/src/main/kotlin/com/divinelink/feature/details/ui/{MovieDetailsResult.kt => MediaDetailsResult.kt} (76%) rename feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/{GetMovieDetailsUseCase.kt => GetMediaDetailsUseCase.kt} (87%) diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMoviesDetailsUseCaseTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt similarity index 58% rename from app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMoviesDetailsUseCaseTest.kt rename to app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt index c7ad4c93..905daf50 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMoviesDetailsUseCaseTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt @@ -2,11 +2,14 @@ package com.andreolas.movierama.details.domain.usecase import app.cash.turbine.test import com.andreolas.factories.VideoFactory +import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory import com.andreolas.movierama.fakes.repository.FakeDetailsRepository import com.andreolas.movierama.fakes.repository.FakeMoviesRepository +import com.andreolas.movierama.fakes.usecase.details.FakeFetchAccountMediaDetailsUseCase import com.divinelink.core.data.details.model.MediaDetailsException import com.divinelink.core.data.details.model.SimilarException import com.divinelink.core.data.details.model.VideosException +import com.divinelink.core.model.details.DetailsMenuOptions import com.divinelink.core.model.details.Movie import com.divinelink.core.model.details.Review import com.divinelink.core.model.details.video.Video @@ -17,17 +20,19 @@ import com.divinelink.core.network.media.model.details.DetailsRequestApi import com.divinelink.core.network.media.model.details.similar.SimilarRequestApi import com.divinelink.core.testing.MainDispatcherRule import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory -import com.divinelink.feature.details.ui.MovieDetailsResult -import com.divinelink.feature.details.usecase.GetMovieDetailsUseCase +import com.divinelink.core.testing.usecase.FakeGetDropdownMenuItemsUseCase +import com.divinelink.feature.details.ui.MediaDetailsResult +import com.divinelink.feature.details.usecase.GetMediaDetailsUseCase import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.last import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test -class GetMoviesDetailsUseCaseTest { +class GetMediaDetailsUseCaseTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @@ -36,6 +41,9 @@ class GetMoviesDetailsUseCaseTest { private lateinit var repository: FakeDetailsRepository private lateinit var moviesRepository: FakeMoviesRepository + private lateinit var fakeFetchAccountMediaDetailsUseCase: FakeFetchAccountMediaDetailsUseCase + private lateinit var fakeGetDropdownMenuItemsUseCase: FakeGetDropdownMenuItemsUseCase + private val request = DetailsRequestApi.Movie(movieId = 555) private val movieDetails = Movie( id = 0, @@ -76,16 +84,20 @@ class GetMoviesDetailsUseCaseTest { fun setUp() { repository = FakeDetailsRepository() moviesRepository = FakeMoviesRepository() + fakeFetchAccountMediaDetailsUseCase = FakeFetchAccountMediaDetailsUseCase() + fakeGetDropdownMenuItemsUseCase = FakeGetDropdownMenuItemsUseCase() } @Test fun `test unknown parameters return failure`() = runTest { val expectedResult = Result.failure(MediaDetailsException()) - val useCase = GetMovieDetailsUseCase( + val useCase = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) useCase(DetailsRequestApi.Unknown).test { @@ -100,17 +112,19 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieDetails(request, Result.success(movieDetails)) // repository.mockFetchMovieReviews(ReviewsRequestApi.Movie(555), Result.failure()) // repository.mockFetchSimilarMovies(SimilarRequestApi.Movie(mediaId = 555), Result.Loading) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).first() assertThat(result).isEqualTo( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.DetailsSuccess( movieDetails.copy( isFavorite = true, ), @@ -125,15 +139,17 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieDetails(request, Result.success(movieDetails)) // repository.mockFetchMovieReviews(ReviewsRequestApi.Movie(555), Result.Loading) // repository.mockFetchSimilarMovies(SimilarRequestApi.Movie(mediaId = 555), Result.Loading) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).first() - assertThat(result).isEqualTo(Result.success(MovieDetailsResult.DetailsSuccess(movieDetails))) + assertThat(result).isEqualTo(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))) } @Test @@ -144,15 +160,17 @@ class GetMoviesDetailsUseCaseTest { Result.success(reviewsList), ) // repository.mockFetchSimilarMovies(SimilarRequestApi.Movie(mediaId = 555), Result.Loading) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).last() - assertThat(result).isEqualTo(Result.success(MovieDetailsResult.ReviewsSuccess(reviewsList))) + assertThat(result).isEqualTo(Result.success(MediaDetailsResult.ReviewsSuccess(reviewsList))) } @Test @@ -163,21 +181,23 @@ class GetMoviesDetailsUseCaseTest { SimilarRequestApi.Movie(movieId = 555), Result.success(similarList), ) - val useCase = GetMovieDetailsUseCase( + val useCase = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) useCase(request).test { assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.DetailsSuccess(movieDetails)), + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.SimilarSuccess(similarList)), + Result.success(MediaDetailsResult.SimilarSuccess(similarList)), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.ReviewsSuccess(reviewsList)), + Result.success(MediaDetailsResult.ReviewsSuccess(reviewsList)), ) this.awaitComplete() } @@ -193,10 +213,12 @@ class GetMoviesDetailsUseCaseTest { SimilarRequestApi.Movie(movieId = 555), Result.success(similarList), ) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).first() @@ -215,10 +237,12 @@ class GetMoviesDetailsUseCaseTest { response = Result.failure(Exception("Oops.")), ) - val useCase = GetMovieDetailsUseCase( + val useCase = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = useCase(request).last() @@ -234,10 +258,12 @@ class GetMoviesDetailsUseCaseTest { Result.failure(SimilarException()), ) - val useCase = GetMovieDetailsUseCase( + val useCase = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = useCase(request).last() @@ -252,30 +278,34 @@ class GetMoviesDetailsUseCaseTest { SimilarRequestApi.Movie(movieId = 555), Result.failure(SimilarException()), ) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).first() - assertThat(result).isEqualTo(Result.success(MovieDetailsResult.DetailsSuccess(movieDetails))) + assertThat(result).isEqualTo(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))) } @Test fun `successfully get movie details even when reviews and similar calls fail`() = runTest { moviesRepository.mockCheckFavorite(555, MediaType.MOVIE, Result.success(false)) repository.mockFetchMovieDetails(request, Result.success(movieDetails)) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).first() - assertThat(result).isEqualTo(Result.success(MovieDetailsResult.DetailsSuccess(movieDetails))) + assertThat(result).isEqualTo(Result.success(MediaDetailsResult.DetailsSuccess(movieDetails))) } // Video tests @@ -291,17 +321,19 @@ class GetMoviesDetailsUseCaseTest { ), ) repository.mockFetchMovieVideos(DetailsRequestApi.Movie(555), Result.success(videoList)) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).last() assertThat( result, - ).isEqualTo(Result.success(MovieDetailsResult.VideosSuccess(videoList.first()))) + ).isEqualTo(Result.success(MediaDetailsResult.VideosSuccess(videoList.first()))) } @Test @@ -316,15 +348,17 @@ class GetMoviesDetailsUseCaseTest { ), ) repository.mockFetchMovieVideos(DetailsRequestApi.Movie(555), Result.success(videoList)) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = flow(request).last() - assertThat(result).isEqualTo(Result.success(MovieDetailsResult.VideosSuccess(null))) + assertThat(result).isEqualTo(Result.success(MediaDetailsResult.VideosSuccess(null))) } @Test @@ -335,18 +369,20 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieDetails(tvRequest, Result.success(movieDetails)) repository.mockFetchMovieVideos(tvRequest, Result.success(videoList)) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) flow(tvRequest).test { assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.DetailsSuccess(movieDetails)), + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.VideosSuccess(videoList.first())), + Result.success(MediaDetailsResult.VideosSuccess(videoList.first())), ) this.awaitComplete() } @@ -359,21 +395,23 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieVideos(tvRequest, Result.success(emptyList())) repository.mockFetchAggregateCredits(Result.success(AggregatedCreditsFactory.credits())) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) flow(tvRequest).test { assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.DetailsSuccess(movieDetails)), + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.CreditsSuccess(AggregatedCreditsFactory.credits())), + Result.success(MediaDetailsResult.CreditsSuccess(AggregatedCreditsFactory.credits())), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.VideosSuccess(null)), + Result.success(MediaDetailsResult.VideosSuccess(null)), ) this.awaitComplete() } @@ -386,18 +424,20 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieVideos(tvRequest, Result.success(emptyList())) repository.mockFetchAggregateCredits(Result.failure(Exception())) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) flow(tvRequest).test { assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.DetailsSuccess(movieDetails)), + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.VideosSuccess(trailer = null)), + Result.success(MediaDetailsResult.VideosSuccess(trailer = null)), ) this.awaitComplete() } @@ -408,18 +448,20 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieDetails(request, Result.success(movieDetails)) repository.mockFetchMovieVideos(request, Result.success(emptyList())) - val flow = GetMovieDetailsUseCase( + val flow = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) flow(request).test { assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.DetailsSuccess(movieDetails)), + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), ) assertThat(this.awaitItem()).isEqualTo( - Result.success(MovieDetailsResult.VideosSuccess(null)), + Result.success(MediaDetailsResult.VideosSuccess(null)), ) this.awaitComplete() } @@ -431,13 +473,120 @@ class GetMoviesDetailsUseCaseTest { repository.mockFetchMovieVideos(DetailsRequestApi.Movie(555), Result.failure(VideosException())) - val useCase = GetMovieDetailsUseCase( + val useCase = GetMediaDetailsUseCase( repository = repository.mock, mediaRepository = moviesRepository.mock, dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, ) val result = useCase(request).last() assertThat(result).isInstanceOf(expectedResult::class.java) } + + @Test + fun `test fetchAccountMediaDetails with success successfully emits data`() = runTest { + val accountDetails = AccountMediaDetailsFactory.Rated() + + val expectedResult = Result.success(MediaDetailsResult.AccountDetailsSuccess(accountDetails)) + + repository.mockFetchMovieDetails(request, Result.success(movieDetails)) + + fakeFetchAccountMediaDetailsUseCase.mockFetchAccountDetails( + flowOf(Result.success(accountDetails)), + ) + + val useCase = GetMediaDetailsUseCase( + repository = repository.mock, + mediaRepository = moviesRepository.mock, + dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, + ) + + useCase(request).test { + assertThat(awaitItem()).isEqualTo( + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), + ) + assertThat(awaitItem()).isEqualTo(expectedResult) + awaitComplete() + } + } + + @Test + fun `test fetchAccountMediaDetails with failure does not emit data`() = runTest { + repository.mockFetchMovieDetails(request, Result.success(movieDetails)) + + fakeFetchAccountMediaDetailsUseCase.mockFetchAccountDetails( + flowOf(Result.failure(Exception("Oops."))), + ) + + val useCase = GetMediaDetailsUseCase( + repository = repository.mock, + mediaRepository = moviesRepository.mock, + dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, + ) + + useCase(request).test { + assertThat(awaitItem()).isEqualTo( + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), + ) + awaitComplete() + } + } + + @Test + fun `test getMenuItemsUseCase with success successfully emits data`() = runTest { + val menuItems = listOf( + DetailsMenuOptions.SHARE, + DetailsMenuOptions.REQUEST, + ) + + val expectedResult = Result.success(MediaDetailsResult.MenuOptionsSuccess(menuItems)) + + repository.mockFetchMovieDetails(request, Result.success(movieDetails)) + + fakeGetDropdownMenuItemsUseCase.mockSuccess(flowOf(Result.success(menuItems))) + + val useCase = GetMediaDetailsUseCase( + repository = repository.mock, + mediaRepository = moviesRepository.mock, + dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, + ) + + useCase(request).test { + assertThat(awaitItem()).isEqualTo( + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), + ) + assertThat(awaitItem()).isEqualTo(expectedResult) + awaitComplete() + } + } + + @Test + fun `test getMenuItemsUseCase with failure does not emit data`() = runTest { + repository.mockFetchMovieDetails(request, Result.success(movieDetails)) + + fakeGetDropdownMenuItemsUseCase + + val useCase = GetMediaDetailsUseCase( + repository = repository.mock, + mediaRepository = moviesRepository.mock, + dispatcher = testDispatcher, + fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, + ) + + useCase(request).test { + assertThat(awaitItem()).isEqualTo( + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), + ) + awaitComplete() + } + } } diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt index 000cf23d..e99a418f 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelRobot.kt @@ -2,21 +2,18 @@ package com.andreolas.movierama.details.ui import androidx.compose.material3.SnackbarResult import androidx.lifecycle.SavedStateHandle -import com.andreolas.movierama.fakes.usecase.FakeGetMoviesDetailsUseCase +import com.andreolas.movierama.fakes.usecase.FakeGetMediaDetailsUseCase import com.andreolas.movierama.fakes.usecase.FakeMarkAsFavoriteUseCase import com.andreolas.movierama.fakes.usecase.details.FakeAddToWatchlistUseCase import com.andreolas.movierama.fakes.usecase.details.FakeDeleteRatingUseCase -import com.andreolas.movierama.fakes.usecase.details.FakeFetchAccountMediaDetailsUseCase import com.andreolas.movierama.fakes.usecase.details.FakeSubmitRatingUseCase -import com.divinelink.core.model.account.AccountMediaDetails import com.divinelink.core.model.media.MediaItem import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule -import com.divinelink.core.testing.usecase.FakeGetDropdownMenuItemsUseCase import com.divinelink.core.testing.usecase.FakeRequestMediaUseCase import com.divinelink.feature.details.ui.DetailsViewModel import com.divinelink.feature.details.ui.DetailsViewState -import com.divinelink.feature.details.ui.MovieDetailsResult +import com.divinelink.feature.details.ui.MediaDetailsResult import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.Flow import org.junit.Rule @@ -29,13 +26,11 @@ class DetailsViewModelRobot { val mainDispatcherRule = MainDispatcherRule() private val fakeMarkAsFavoriteUseCase = FakeMarkAsFavoriteUseCase() - private val fakeGetMovieDetailsUseCase = FakeGetMoviesDetailsUseCase() - private val fakeFetchAccountMediaDetailsUseCase = FakeFetchAccountMediaDetailsUseCase() + private val fakeGetMovieDetailsUseCase = FakeGetMediaDetailsUseCase() private val fakeSubmitRatingUseCase = FakeSubmitRatingUseCase() private val fakeDeleteRatingUseCase = FakeDeleteRatingUseCase() private val fakeAddToWatchListUseCase = FakeAddToWatchlistUseCase() private val fakeRequestMediaUseCase = FakeRequestMediaUseCase() - private val fakeGetDropdownMenuItemsUseCase = FakeGetDropdownMenuItemsUseCase() fun buildViewModel( id: Int, @@ -43,13 +38,11 @@ class DetailsViewModelRobot { ) = apply { viewModel = DetailsViewModel( onMarkAsFavoriteUseCase = fakeMarkAsFavoriteUseCase, - getMovieDetailsUseCase = fakeGetMovieDetailsUseCase.mock, - fetchAccountMediaDetailsUseCase = fakeFetchAccountMediaDetailsUseCase.mock, + getMediaDetailsUseCase = fakeGetMovieDetailsUseCase.mock, submitRatingUseCase = fakeSubmitRatingUseCase.mock, deleteRatingUseCase = fakeDeleteRatingUseCase.mock, addToWatchlistUseCase = fakeAddToWatchListUseCase.mock, requestMediaUseCase = fakeRequestMediaUseCase.mock, - getMenuItemsUseCase = fakeGetDropdownMenuItemsUseCase.mock, savedStateHandle = SavedStateHandle( mapOf( "id" to id, @@ -66,8 +59,8 @@ class DetailsViewModelRobot { assertThat(viewModel.viewState.value).isEqualTo(expectedViewState) } - fun mockFetchMovieDetails(response: Flow>) = apply { - fakeGetMovieDetailsUseCase.mockFetchMovieDetails( + fun mockFetchMediaDetails(response: Flow>) = apply { + fakeGetMovieDetailsUseCase.mockFetchMediaDetails( response = response, ) } @@ -118,12 +111,6 @@ class DetailsViewModelRobot { ) } - fun mockFetchAccountMediaDetails(response: Flow>) = apply { - fakeFetchAccountMediaDetailsUseCase.mockFetchAccountDetails( - response = response, - ) - } - fun mockSubmitRate(response: Flow>) = apply { fakeSubmitRatingUseCase.mockSubmitRate( response = response, diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt index 18d25767..2b16b4d8 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/ui/DetailsViewModelTest.kt @@ -9,8 +9,11 @@ import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsF import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory.toWizard import com.divinelink.core.data.details.model.MediaDetailsException import com.divinelink.core.data.session.model.SessionException +import com.divinelink.core.model.account.AccountMediaDetails +import com.divinelink.core.model.details.DetailsMenuOptions import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory import com.divinelink.core.testing.factories.model.details.MediaDetailsFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory.toWizard @@ -18,7 +21,7 @@ import com.divinelink.core.ui.UIText import com.divinelink.core.ui.snackbar.SnackbarMessage import com.divinelink.feature.details.R import com.divinelink.feature.details.ui.DetailsViewState -import com.divinelink.feature.details.ui.MovieDetailsResult +import com.divinelink.feature.details.ui.MediaDetailsResult import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -27,11 +30,7 @@ import com.divinelink.core.ui.R as uiR class DetailsViewModelTest { - private val testRobot = DetailsViewModelRobot().apply { - mockFetchAccountMediaDetails( - response = flowOf(Result.success(AccountMediaDetailsFactory.NotRated())), - ) - } + private val testRobot = DetailsViewModelRobot() @get:Rule val mainDispatcherRule = MainDispatcherRule() @@ -41,19 +40,24 @@ class DetailsViewModelTest { private val similarMovies = MediaItemFactory.MoviesList() private val movieDetails = MediaDetailsFactory.FightClub() + private val tvDetails = MediaDetailsFactory.TheOffice() private val reviewsList = ReviewFactory.ReviewList() + private fun defaultDetails( + result: MediaDetailsResult, + accountDetails: AccountMediaDetails = AccountMediaDetailsFactory.NotRated(), + ) = flowOf( + Result.success(result), + Result.success(MediaDetailsResult.AccountDetailsSuccess(accountDetails)), + ) + @Test fun `successful initialise viewModel`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), + .mockFetchMediaDetails( + response = defaultDetails( + MediaDetailsResult.DetailsSuccess(movieDetails), ), ) .buildViewModel(mediaId, MediaType.MOVIE) @@ -71,14 +75,8 @@ class DetailsViewModelTest { @Test fun `given success details response then I expect MovieDetails`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .buildViewModel(mediaId, MediaType.MOVIE) .assertViewState( @@ -95,14 +93,8 @@ class DetailsViewModelTest { @Test fun `given success reviews response then I expect ReviewsList`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.ReviewsSuccess( - reviewsList, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.ReviewsSuccess(reviewsList)), ) .buildViewModel(mediaId, MediaType.MOVIE) .assertViewState( @@ -119,16 +111,13 @@ class DetailsViewModelTest { @Test fun `given success details and reviews response then I expect combined flows`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), + Result.success(MediaDetailsResult.ReviewsSuccess(reviewsList)), Result.success( - MovieDetailsResult.ReviewsSuccess( - reviewsList, - ), - ), - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), ), ), ), @@ -151,15 +140,20 @@ class DetailsViewModelTest { @Test fun `given success details and similar response then I expect Loading State`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( Result.success( - MovieDetailsResult.ReviewsSuccess( + MediaDetailsResult.ReviewsSuccess( reviewsList, ), ), Result.success( - MovieDetailsResult.SimilarSuccess( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), + ), + ), + Result.success( + MediaDetailsResult.SimilarSuccess( similarMovies, ), ), @@ -181,16 +175,21 @@ class DetailsViewModelTest { @Test fun `given error I expect FatalError`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( - Result.success(MovieDetailsResult.Failure.FatalError()), + Result.success(MediaDetailsResult.Failure.FatalError()), Result.success( - MovieDetailsResult.SimilarSuccess( + MediaDetailsResult.SimilarSuccess( similarMovies, ), ), Result.success( - MovieDetailsResult.ReviewsSuccess( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), + ), + ), + Result.success( + MediaDetailsResult.ReviewsSuccess( reviewsList, ), ), @@ -204,7 +203,7 @@ class DetailsViewModelTest { isLoading = false, reviews = reviewsList, similarMovies = similarMovies, - error = MovieDetailsResult.Failure.FatalError().message, + error = MediaDetailsResult.Failure.FatalError().message, userDetails = AccountMediaDetailsFactory.NotRated(), ), ) @@ -213,16 +212,21 @@ class DetailsViewModelTest { @Test fun `given unknown error I expect general error`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( - Result.success(MovieDetailsResult.Failure.Unknown), + Result.success(MediaDetailsResult.Failure.Unknown), Result.success( - MovieDetailsResult.SimilarSuccess( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), + ), + ), + Result.success( + MediaDetailsResult.SimilarSuccess( similarMovies, ), ), Result.success( - MovieDetailsResult.ReviewsSuccess( + MediaDetailsResult.ReviewsSuccess( reviewsList, ), ), @@ -237,7 +241,7 @@ class DetailsViewModelTest { reviews = reviewsList, userDetails = AccountMediaDetailsFactory.NotRated(), similarMovies = similarMovies, - error = MovieDetailsResult.Failure.Unknown.message, + error = MediaDetailsResult.Failure.Unknown.message, ), ) } @@ -245,16 +249,21 @@ class DetailsViewModelTest { @Test fun `on MovieDetails Exception I expect Fatal Error`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( Result.failure(MediaDetailsException()), Result.success( - MovieDetailsResult.SimilarSuccess( + MediaDetailsResult.SimilarSuccess( similarMovies, ), ), Result.success( - MovieDetailsResult.ReviewsSuccess( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), + ), + ), + Result.success( + MediaDetailsResult.ReviewsSuccess( reviewsList, ), ), @@ -269,7 +278,7 @@ class DetailsViewModelTest { isLoading = false, reviews = reviewsList, similarMovies = similarMovies, - error = MovieDetailsResult.Failure.FatalError().message, + error = MediaDetailsResult.Failure.FatalError().message, ), ) } @@ -277,16 +286,21 @@ class DetailsViewModelTest { @Test fun `on some other exception I expect Unknown error`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( Result.failure(Exception()), Result.success( - MovieDetailsResult.SimilarSuccess( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), + ), + ), + Result.success( + MediaDetailsResult.SimilarSuccess( similarMovies, ), ), Result.success( - MovieDetailsResult.ReviewsSuccess( + MediaDetailsResult.ReviewsSuccess( reviewsList, ), ), @@ -303,7 +317,7 @@ class DetailsViewModelTest { userDetails = AccountMediaDetailsFactory.NotRated().toWizard { withId(mediaId) }, - error = MovieDetailsResult.Failure.Unknown.message, + error = MediaDetailsResult.Failure.Unknown.message, ), ) } @@ -311,15 +325,9 @@ class DetailsViewModelTest { @Test fun `given movie is liked when MaskAsFavorite clicked then I expect to un mark it`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails.copy( - isFavorite = true, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails( + MediaDetailsResult.DetailsSuccess(movieDetails.copy(isFavorite = true)), ), ) .mockMarkAsFavoriteUseCase( @@ -359,14 +367,8 @@ class DetailsViewModelTest { fun `given movie is not favorite when MaskAsFavorite clicked then I expect to mark it`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .mockMarkAsFavoriteUseCase( media = MediaItemFactory.FightClub(), @@ -400,18 +402,23 @@ class DetailsViewModelTest { @Test fun `given success details and movies response then I expect combined flows`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.DetailsSuccess( movieDetails, ), ), Result.success( - MovieDetailsResult.VideosSuccess( + MediaDetailsResult.VideosSuccess( VideoFactory.Youtube(), ), ), + Result.success( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.NotRated(), + ), + ), ), ) .buildViewModel( @@ -433,18 +440,20 @@ class DetailsViewModelTest { @Test fun `given account media details with rated I expect user rating`() = runTest { testRobot - .mockFetchMovieDetails( + .mockFetchMediaDetails( response = flowOf( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.DetailsSuccess( movieDetails, ), ), + Result.success( + MediaDetailsResult.AccountDetailsSuccess( + AccountMediaDetailsFactory.Rated(), + ), + ), ), ) - .mockFetchAccountMediaDetails( - response = flowOf(Result.success(AccountMediaDetailsFactory.Rated())), - ) .buildViewModel(mediaId, MediaType.MOVIE) .assertViewState( DetailsViewState( @@ -460,17 +469,8 @@ class DetailsViewModelTest { @Test fun `given non rated media I expect no user rating`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), - ) - .mockFetchAccountMediaDetails( - response = flowOf(Result.success(AccountMediaDetailsFactory.NotRated())), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .buildViewModel(mediaId, MediaType.MOVIE) .assertViewState( @@ -487,14 +487,8 @@ class DetailsViewModelTest { @Test fun `given success submit rate, when I submit rate, then I expect success message`() { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .mockSubmitRate( response = flowOf(Result.success(Unit)), @@ -539,14 +533,8 @@ class DetailsViewModelTest { fun `given NoSession error submit rate, when I submit, then I expect error message`() { lateinit var viewModel: com.divinelink.feature.details.ui.DetailsViewModel testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .mockSubmitRate( response = flowOf(Result.failure(SessionException.Unauthenticated())), @@ -576,14 +564,8 @@ class DetailsViewModelTest { fun `given NoSession error, when login action clicked, then I expect navigation to login`() { lateinit var viewModel: com.divinelink.feature.details.ui.DetailsViewModel testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .mockSubmitRate( response = flowOf(Result.failure(SessionException.Unauthenticated())), @@ -624,14 +606,8 @@ class DetailsViewModelTest { @Test fun `given navigation to login, when I consume it, then I expect navigation to be null`() { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .buildViewModel(mediaId, MediaType.MOVIE) .onNavigateToLogin(SnackbarResult.ActionPerformed) @@ -662,14 +638,8 @@ class DetailsViewModelTest { .mockSubmitRate( response = flowOf(Result.success(Unit)), ) - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .buildViewModel(mediaId, MediaType.MOVIE) .onAddRateClicked() @@ -724,14 +694,8 @@ class DetailsViewModelTest { @Test fun `test onAddRateClicked opens bottom sheet`() { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .buildViewModel(mediaId, MediaType.MOVIE) .onAddRateClicked() @@ -750,14 +714,8 @@ class DetailsViewModelTest { @Test fun `test onDismissRateDialog hides dialog`() { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .buildViewModel(mediaId, MediaType.MOVIE) .onAddRateClicked() @@ -787,18 +745,12 @@ class DetailsViewModelTest { @Test fun `given rated movie when I delete rating then I expect no user rating`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), + .mockFetchMediaDetails( + response = defaultDetails( + result = MediaDetailsResult.DetailsSuccess(movieDetails), + accountDetails = AccountMediaDetailsFactory.Rated(), ), ) - .mockFetchAccountMediaDetails( - response = flowOf(Result.success(AccountMediaDetailsFactory.Rated())), - ) .mockDeleteRating( response = flowOf(Result.success(Unit)), ) @@ -837,14 +789,8 @@ class DetailsViewModelTest { lateinit var viewModel: com.divinelink.feature.details.ui.DetailsViewModel testRobot - .mockFetchMovieDetails( - response = flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .mockAddToWatchlist( response = flowOf(Result.failure(SessionException.InvalidAccountId())), @@ -872,8 +818,8 @@ class DetailsViewModelTest { @Test fun `given error when I add to watchlist I expect general error`() = runTest { testRobot - .mockFetchMovieDetails( - response = flowOf(Result.success(MovieDetailsResult.DetailsSuccess(movieDetails))), + .mockFetchMediaDetails( + response = defaultDetails(MediaDetailsResult.DetailsSuccess(movieDetails)), ) .mockAddToWatchlist( response = flowOf(Result.failure(Exception())), @@ -897,18 +843,10 @@ class DetailsViewModelTest { @Test fun `given item on watchlist when I add to watchlist I expect removed message`() = runTest { testRobot - .mockFetchMovieDetails( - flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), - ) - .mockFetchAccountMediaDetails( - flowOf( - Result.success(AccountMediaDetailsFactory.Rated().toWizard { withWatchlist(true) }), + .mockFetchMediaDetails( + response = defaultDetails( + result = MediaDetailsResult.DetailsSuccess(movieDetails), + accountDetails = AccountMediaDetailsFactory.Rated().toWizard { withWatchlist(true) }, ), ) .mockAddToWatchlist(flowOf(Result.success(Unit))) @@ -943,18 +881,10 @@ class DetailsViewModelTest { @Test fun `given item not on watchlist when I add to watchlist I expect added message`() = runTest { testRobot - .mockFetchMovieDetails( - flowOf( - Result.success( - MovieDetailsResult.DetailsSuccess( - movieDetails, - ), - ), - ), - ) - .mockFetchAccountMediaDetails( - flowOf( - Result.success(AccountMediaDetailsFactory.Rated().toWizard { withWatchlist(false) }), + .mockFetchMediaDetails( + response = defaultDetails( + result = MediaDetailsResult.DetailsSuccess(movieDetails), + accountDetails = AccountMediaDetailsFactory.Rated().toWizard { withWatchlist(false) }, ), ) .mockAddToWatchlist(flowOf(Result.success(Unit))) @@ -985,4 +915,51 @@ class DetailsViewModelTest { ), ) } + + @Test + fun `test MediaDetailsResult MenuOption updates menu items`() = runTest { + testRobot + .mockFetchMediaDetails( + response = flowOf( + Result.success(MediaDetailsResult.DetailsSuccess(movieDetails)), + Result.success( + MediaDetailsResult.MenuOptionsSuccess(listOf(DetailsMenuOptions.SHARE)), + ), + ), + ) + .buildViewModel(mediaId, MediaType.MOVIE) + .assertViewState( + DetailsViewState( + mediaType = MediaType.MOVIE, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = movieDetails, + menuOptions = listOf(DetailsMenuOptions.SHARE), + ), + ) + } + + @Test + fun `test on CreditsSuccess MediaDetailsResult update tvCredits`() = runTest { + val credits = AggregatedCreditsFactory.credits() + testRobot + .mockFetchMediaDetails( + response = flowOf( + Result.success(MediaDetailsResult.DetailsSuccess(tvDetails)), + Result.success(MediaDetailsResult.CreditsSuccess(credits)), + ), + ) + .buildViewModel(mediaId, MediaType.TV) + .assertViewState( + DetailsViewState( + mediaType = MediaType.TV, + mediaId = mediaId, + isLoading = false, + userDetails = null, + mediaDetails = tvDetails, + tvCredits = credits, + ), + ) + } } diff --git a/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMediaDetailsUseCase.kt b/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMediaDetailsUseCase.kt new file mode 100644 index 00000000..d742c40f --- /dev/null +++ b/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMediaDetailsUseCase.kt @@ -0,0 +1,17 @@ +package com.andreolas.movierama.fakes.usecase + +import com.divinelink.feature.details.ui.MediaDetailsResult +import com.divinelink.feature.details.usecase.GetMediaDetailsUseCase +import kotlinx.coroutines.flow.Flow +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class FakeGetMediaDetailsUseCase { + + val mock: GetMediaDetailsUseCase = mock() + + fun mockFetchMediaDetails(response: Flow>) { + whenever(mock.invoke(any())).thenReturn(response) + } +} diff --git a/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMoviesDetailsUseCase.kt b/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMoviesDetailsUseCase.kt deleted file mode 100644 index 77f3aa1e..00000000 --- a/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/FakeGetMoviesDetailsUseCase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.andreolas.movierama.fakes.usecase - -import com.divinelink.feature.details.ui.MovieDetailsResult -import com.divinelink.feature.details.usecase.GetMovieDetailsUseCase -import kotlinx.coroutines.flow.Flow -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -class FakeGetMoviesDetailsUseCase { - - val mock: GetMovieDetailsUseCase = mock() - - fun mockFetchMovieDetails(response: Flow>) { - whenever(mock.invoke(any())).thenReturn(response) - } -} diff --git a/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/details/FakeFetchAccountMediaDetailsUseCase.kt b/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/details/FakeFetchAccountMediaDetailsUseCase.kt index 4786d01a..ed40a357 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/details/FakeFetchAccountMediaDetailsUseCase.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/fakes/usecase/details/FakeFetchAccountMediaDetailsUseCase.kt @@ -1,6 +1,7 @@ package com.andreolas.movierama.fakes.usecase.details import com.divinelink.core.model.account.AccountMediaDetails +import com.divinelink.feature.details.usecase.FetchAccountMediaDetailsUseCase import kotlinx.coroutines.flow.Flow import org.mockito.kotlin.any import org.mockito.kotlin.mock @@ -8,7 +9,7 @@ import org.mockito.kotlin.whenever class FakeFetchAccountMediaDetailsUseCase { - val mock: com.divinelink.feature.details.usecase.FetchAccountMediaDetailsUseCase = mock() + val mock: FetchAccountMediaDetailsUseCase = mock() fun mockFetchAccountDetails(response: Flow>) { whenever(mock.invoke(any())).thenReturn(response) diff --git a/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt b/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt index 7b81e407..e9427a8c 100644 --- a/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt +++ b/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight import androidx.lifecycle.SavedStateHandle import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory -import com.andreolas.movierama.fakes.usecase.FakeGetMoviesDetailsUseCase +import com.andreolas.movierama.fakes.usecase.FakeGetMediaDetailsUseCase import com.andreolas.movierama.fakes.usecase.FakeMarkAsFavoriteUseCase import com.andreolas.movierama.fakes.usecase.details.FakeAddToWatchlistUseCase import com.andreolas.movierama.fakes.usecase.details.FakeDeleteRatingUseCase @@ -30,7 +30,7 @@ import com.divinelink.core.ui.components.details.similar.SIMILAR_MOVIES_SCROLLAB import com.divinelink.feature.details.screens.destinations.DetailsScreenDestination import com.divinelink.feature.details.ui.DetailsNavArguments import com.divinelink.feature.details.ui.DetailsScreen -import com.divinelink.feature.details.ui.MovieDetailsResult +import com.divinelink.feature.details.ui.MediaDetailsResult import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Test @@ -41,7 +41,7 @@ class DetailsScreenTest : ComposeTest() { @Test fun navigateToAnotherDetailsScreen() { - val getMovieDetailsUseCase = FakeGetMoviesDetailsUseCase() + val getMovieDetailsUseCase = FakeGetMediaDetailsUseCase() val markAsFavoriteUseCase = FakeMarkAsFavoriteUseCase() val fetchAccountMediaDetailsUseCase = FakeFetchAccountMediaDetailsUseCase() val submitRateUseCase = FakeSubmitRatingUseCase() @@ -67,15 +67,15 @@ class DetailsScreenTest : ComposeTest() { ), ) - getMovieDetailsUseCase.mockFetchMovieDetails( + getMovieDetailsUseCase.mockFetchMediaDetails( response = flowOf( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.DetailsSuccess( mediaDetails = MediaDetailsFactory.FightClub(), ), ), Result.success( - MovieDetailsResult.SimilarSuccess( + MediaDetailsResult.SimilarSuccess( similar = MediaItemFactory.MoviesList(), ), ), @@ -86,14 +86,12 @@ class DetailsScreenTest : ComposeTest() { DetailsScreen( navigator = destinationsNavigator, viewModel = com.divinelink.feature.details.ui.DetailsViewModel( - getMovieDetailsUseCase = getMovieDetailsUseCase.mock, + getMediaDetailsUseCase = getMovieDetailsUseCase.mock, onMarkAsFavoriteUseCase = markAsFavoriteUseCase, - fetchAccountMediaDetailsUseCase = fetchAccountMediaDetailsUseCase.mock, submitRatingUseCase = submitRateUseCase.mock, deleteRatingUseCase = deleteRatingUseCase.mock, addToWatchlistUseCase = addToWatchlistUseCase.mock, requestMediaUseCase = requestMediaUseCase.mock, - getMenuItemsUseCase = getMenuItemsUseCase.mock, savedStateHandle = SavedStateHandle( mapOf( "id" to 0, @@ -101,7 +99,6 @@ class DetailsScreenTest : ComposeTest() { "mediaType" to MediaType.MOVIE.value, ), ), - ), ) } @@ -155,39 +152,37 @@ class DetailsScreenTest : ComposeTest() { @Test fun `test rate dialog is visible when your rating is clicked`() = runTest { - val getMovieDetailsUseCase = FakeGetMoviesDetailsUseCase() + val getMovieDetailsUseCase = FakeGetMediaDetailsUseCase() val markAsFavoriteUseCase = FakeMarkAsFavoriteUseCase() - val fetchAccountMediaDetailsUseCase = FakeFetchAccountMediaDetailsUseCase() val submitRateUseCase = FakeSubmitRatingUseCase() val deleteRatingUseCase = FakeDeleteRatingUseCase() val addToWatchlistUseCase = FakeAddToWatchlistUseCase() val requestMediaUseCase = FakeRequestMediaUseCase() - val getMenuItemsUseCase = FakeGetDropdownMenuItemsUseCase() val destinationsNavigator = FakeDestinationsNavigator() - fetchAccountMediaDetailsUseCase.mockFetchAccountDetails( - response = flowOf(Result.success(AccountMediaDetailsFactory.Rated())), - ) - - getMovieDetailsUseCase.mockFetchMovieDetails( + getMovieDetailsUseCase.mockFetchMediaDetails( response = flowOf( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.AccountDetailsSuccess( + accountDetails = AccountMediaDetailsFactory.Rated(), + ), + ), + Result.success( + MediaDetailsResult.DetailsSuccess( mediaDetails = MediaDetailsFactory.FightClub(), ), ), + ), ) val viewModel = com.divinelink.feature.details.ui.DetailsViewModel( - getMovieDetailsUseCase = getMovieDetailsUseCase.mock, + getMediaDetailsUseCase = getMovieDetailsUseCase.mock, onMarkAsFavoriteUseCase = markAsFavoriteUseCase, - fetchAccountMediaDetailsUseCase = fetchAccountMediaDetailsUseCase.mock, submitRatingUseCase = submitRateUseCase.mock, deleteRatingUseCase = deleteRatingUseCase.mock, addToWatchlistUseCase = addToWatchlistUseCase.mock, requestMediaUseCase = requestMediaUseCase.mock, - getMenuItemsUseCase = getMenuItemsUseCase.mock, savedStateHandle = SavedStateHandle( mapOf( "id" to 0, @@ -216,7 +211,7 @@ class DetailsScreenTest : ComposeTest() { @Test fun `test rate dialog onSubmitRate`() = runTest { - val getMovieDetailsUseCase = FakeGetMoviesDetailsUseCase() + val getMovieDetailsUseCase = FakeGetMediaDetailsUseCase() val markAsFavoriteUseCase = FakeMarkAsFavoriteUseCase() val fetchAccountMediaDetailsUseCase = FakeFetchAccountMediaDetailsUseCase() val submitRateUseCase = FakeSubmitRatingUseCase() @@ -234,10 +229,10 @@ class DetailsScreenTest : ComposeTest() { response = flowOf(Result.success(Unit)), ) - getMovieDetailsUseCase.mockFetchMovieDetails( + getMovieDetailsUseCase.mockFetchMediaDetails( response = flowOf( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.DetailsSuccess( mediaDetails = MediaDetailsFactory.FightClub(), ), ), @@ -245,14 +240,12 @@ class DetailsScreenTest : ComposeTest() { ) val viewModel = com.divinelink.feature.details.ui.DetailsViewModel( - getMovieDetailsUseCase = getMovieDetailsUseCase.mock, + getMediaDetailsUseCase = getMovieDetailsUseCase.mock, onMarkAsFavoriteUseCase = markAsFavoriteUseCase, - fetchAccountMediaDetailsUseCase = fetchAccountMediaDetailsUseCase.mock, submitRatingUseCase = submitRateUseCase.mock, deleteRatingUseCase = deleteRatingUseCase.mock, addToWatchlistUseCase = addToWatchlistUseCase.mock, requestMediaUseCase = requestMediaUseCase.mock, - getMenuItemsUseCase = getMenuItemsUseCase.mock, savedStateHandle = SavedStateHandle( mapOf( "id" to 0, diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt index cdb0aecf..a901f36f 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/factories/details/credits/AggregatedCreditsFactory.kt @@ -22,11 +22,11 @@ object AggregatedCreditsFactory { ), ) - // Data fetched from the database is sorted + // Data fetched from the database is sorted by name fun partialCredits() = AggregateCredits( id = 2316, cast = SeriesCastFactory.cast().take(2), - crewDepartments = listOf(SeriesCrewListFactory.camera()), + crewDepartments = listOf(SeriesCrewListFactory.sortedCameraDepartment()), ) } @@ -241,6 +241,56 @@ object SeriesCrewListFactory { ) fun camera() = SeriesCrewDepartment( + department = "Camera", + crewList = listOf( + Person( + id = 1215572, + name = "Randall Einhorn", + profilePath = null, + role = PersonRole.Crew( + job = "Director of Photography", + creditId = "5bdaa68f92514153f500859f", + totalEpisodes = 3, + department = "Camera", + ), + ), + Person( + id = 1879373, + name = "Dale Alexander", + profilePath = null, + role = PersonRole.Crew( + job = "Key Grip", + creditId = "5bdaa7d90e0a2603c60086d9", + totalEpisodes = 3, + department = "Camera", + ), + ), + Person( + id = 2166021, + name = "Ron Nichols", + profilePath = null, + role = PersonRole.Crew( + job = "Key Grip", + creditId = "5bdaa3e40e0a2603b1008d3f", + totalEpisodes = 1, + department = "Camera", + ), + ), + Person( + id = 67864, + name = "Peter Smokler", + profilePath = null, + role = PersonRole.Crew( + job = "Director of Photography", + creditId = "5bdaa2d4c3a368078f007f5c", + totalEpisodes = 1, + department = "Camera", + ), + ), + ), + ) + + fun sortedCameraDepartment() = SeriesCrewDepartment( department = "Camera", crewList = listOf( Person( diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeGetDropdownMenuItemsUseCase.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeGetDropdownMenuItemsUseCase.kt index 071fff0d..1213e896 100644 --- a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeGetDropdownMenuItemsUseCase.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/FakeGetDropdownMenuItemsUseCase.kt @@ -16,7 +16,7 @@ class FakeGetDropdownMenuItemsUseCase { mockFailure() } - private fun mockFailure() { + fun mockFailure() { whenever( mock.invoke(any()), ).thenReturn( diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt index 93c00596..e6199ea3 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/DetailsViewModel.kt @@ -22,7 +22,7 @@ import com.divinelink.feature.details.usecase.AddToWatchlistParameters import com.divinelink.feature.details.usecase.AddToWatchlistUseCase import com.divinelink.feature.details.usecase.DeleteRatingParameters import com.divinelink.feature.details.usecase.DeleteRatingUseCase -import com.divinelink.feature.details.usecase.GetMovieDetailsUseCase +import com.divinelink.feature.details.usecase.GetMediaDetailsUseCase import com.divinelink.feature.details.usecase.SubmitRatingParameters import com.divinelink.feature.details.usecase.SubmitRatingUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -42,7 +42,7 @@ import com.divinelink.core.ui.R as uiR @HiltViewModel class DetailsViewModel @Inject constructor( - getMovieDetailsUseCase: GetMovieDetailsUseCase, + getMediaDetailsUseCase: GetMediaDetailsUseCase, private val onMarkAsFavoriteUseCase: MarkAsFavoriteUseCase, private val submitRatingUseCase: SubmitRatingUseCase, private val deleteRatingUseCase: DeleteRatingUseCase, @@ -86,56 +86,56 @@ class DetailsViewModel @Inject constructor( MediaType.UNKNOWN -> DetailsRequestApi.Unknown } - getMovieDetailsUseCase(parameters = requestApi) + getMediaDetailsUseCase(parameters = requestApi) .onEach { result -> result.onSuccess { _viewState.update { viewState -> when (result.data) { - is MovieDetailsResult.DetailsSuccess -> { + is MediaDetailsResult.DetailsSuccess -> { viewState.copy( isLoading = false, - mediaDetails = (result.data as MovieDetailsResult.DetailsSuccess).mediaDetails, + mediaDetails = (result.data as MediaDetailsResult.DetailsSuccess).mediaDetails, ) } - is MovieDetailsResult.ReviewsSuccess -> viewState.copy( - reviews = (result.data as MovieDetailsResult.ReviewsSuccess).reviews, + is MediaDetailsResult.ReviewsSuccess -> viewState.copy( + reviews = (result.data as MediaDetailsResult.ReviewsSuccess).reviews, ) - is MovieDetailsResult.SimilarSuccess -> viewState.copy( - similarMovies = (result.data as MovieDetailsResult.SimilarSuccess).similar, + is MediaDetailsResult.SimilarSuccess -> viewState.copy( + similarMovies = (result.data as MediaDetailsResult.SimilarSuccess).similar, ) - is MovieDetailsResult.VideosSuccess -> viewState.copy( - trailer = (result.data as MovieDetailsResult.VideosSuccess).trailer, + is MediaDetailsResult.VideosSuccess -> viewState.copy( + trailer = (result.data as MediaDetailsResult.VideosSuccess).trailer, ) - is MovieDetailsResult.CreditsSuccess -> { - val credits = (result.data as MovieDetailsResult.CreditsSuccess).aggregateCredits + is MediaDetailsResult.CreditsSuccess -> { + val credits = (result.data as MediaDetailsResult.CreditsSuccess).aggregateCredits viewState.copy(tvCredits = credits) } - is MovieDetailsResult.AccountDetailsSuccess -> { - val successData = (result.data as MovieDetailsResult.AccountDetailsSuccess) + is MediaDetailsResult.AccountDetailsSuccess -> { + val successData = (result.data as MediaDetailsResult.AccountDetailsSuccess) viewState.copy( userDetails = successData.accountDetails, ) } - is MovieDetailsResult.MenuOptionsSuccess -> { - val successData = (result.data as MovieDetailsResult.MenuOptionsSuccess) + is MediaDetailsResult.MenuOptionsSuccess -> { + val successData = (result.data as MediaDetailsResult.MenuOptionsSuccess) viewState.copy( menuOptions = successData.menuOptions, ) } - is MovieDetailsResult.Failure.FatalError -> viewState.copy( - error = (result.data as MovieDetailsResult.Failure.FatalError).message, + is MediaDetailsResult.Failure.FatalError -> viewState.copy( + error = (result.data as MediaDetailsResult.Failure.FatalError).message, isLoading = false, ) - MovieDetailsResult.Failure.Unknown -> viewState.copy( - error = MovieDetailsResult.Failure.Unknown.message, + MediaDetailsResult.Failure.Unknown -> viewState.copy( + error = MediaDetailsResult.Failure.Unknown.message, isLoading = false, ) } @@ -144,14 +144,14 @@ class DetailsViewModel @Inject constructor( if (it is MediaDetailsException) { _viewState.update { viewState -> viewState.copy( - error = MovieDetailsResult.Failure.FatalError().message, + error = MediaDetailsResult.Failure.FatalError().message, isLoading = false, ) } } else { _viewState.update { viewState -> viewState.copy( - error = MovieDetailsResult.Failure.Unknown.message, + error = MediaDetailsResult.Failure.Unknown.message, isLoading = false, ) } diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MediaDetailsResult.kt similarity index 76% rename from feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt rename to feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MediaDetailsResult.kt index dab69539..bb7601a4 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MovieDetailsResult.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/ui/MediaDetailsResult.kt @@ -13,24 +13,24 @@ import com.divinelink.feature.details.R /** * A collection of possible results for an attempt to fetch movie details, similar movies and reviews. */ -sealed class MovieDetailsResult { - data class AccountDetailsSuccess(val accountDetails: AccountMediaDetails) : MovieDetailsResult() +sealed class MediaDetailsResult { + data class AccountDetailsSuccess(val accountDetails: AccountMediaDetails) : MediaDetailsResult() - data class DetailsSuccess(val mediaDetails: MediaDetails) : MovieDetailsResult() + data class DetailsSuccess(val mediaDetails: MediaDetails) : MediaDetailsResult() - data class ReviewsSuccess(val reviews: List) : MovieDetailsResult() + data class ReviewsSuccess(val reviews: List) : MediaDetailsResult() - data class SimilarSuccess(val similar: List) : MovieDetailsResult() + data class SimilarSuccess(val similar: List) : MediaDetailsResult() - data class VideosSuccess(val trailer: Video?) : MovieDetailsResult() + data class VideosSuccess(val trailer: Video?) : MediaDetailsResult() - data class CreditsSuccess(val aggregateCredits: AggregateCredits) : MovieDetailsResult() + data class CreditsSuccess(val aggregateCredits: AggregateCredits) : MediaDetailsResult() - data class MenuOptionsSuccess(val menuOptions: List) : MovieDetailsResult() + data class MenuOptionsSuccess(val menuOptions: List) : MediaDetailsResult() sealed class Failure( open val message: UIText = UIText.ResourceText(R.string.general_error_message), - ) : MovieDetailsResult() { + ) : MediaDetailsResult() { data class FatalError( override val message: UIText = UIText.ResourceText( diff --git a/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt b/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMediaDetailsUseCase.kt similarity index 87% rename from feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt rename to feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMediaDetailsUseCase.kt index 632353ad..5fed98c5 100644 --- a/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMovieDetailsUseCase.kt +++ b/feature/details/src/main/kotlin/com/divinelink/feature/details/usecase/GetMediaDetailsUseCase.kt @@ -12,7 +12,7 @@ import com.divinelink.core.domain.GetDropdownMenuItemsUseCase import com.divinelink.core.model.media.MediaType import com.divinelink.core.network.media.model.details.DetailsRequestApi import com.divinelink.core.network.media.model.details.similar.SimilarRequestApi -import com.divinelink.feature.details.ui.MovieDetailsResult +import com.divinelink.feature.details.ui.MediaDetailsResult import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch @@ -22,14 +22,14 @@ import timber.log.Timber import javax.inject.Inject @Suppress("LongMethod") -open class GetMovieDetailsUseCase @Inject constructor( +open class GetMediaDetailsUseCase @Inject constructor( private val repository: DetailsRepository, private val mediaRepository: MediaRepository, private val fetchAccountMediaDetailsUseCase: FetchAccountMediaDetailsUseCase, private val getMenuItemsUseCase: GetDropdownMenuItemsUseCase, @IoDispatcher val dispatcher: CoroutineDispatcher, -) : FlowUseCase(dispatcher) { - override fun execute(parameters: DetailsRequestApi): Flow> = +) : FlowUseCase(dispatcher) { + override fun execute(parameters: DetailsRequestApi): Flow> = channelFlow { if (parameters == DetailsRequestApi.Unknown) { send(Result.failure(MediaDetailsException())) @@ -62,7 +62,7 @@ open class GetMovieDetailsUseCase @Inject constructor( .collect { result -> send( Result.success( - MovieDetailsResult.DetailsSuccess( + MediaDetailsResult.DetailsSuccess( result.data.copy(isFavorite = isFavorite.getOrNull() ?: false), ), ), @@ -75,7 +75,7 @@ open class GetMovieDetailsUseCase @Inject constructor( .catch { Timber.e(it) } .collect { result -> result.onSuccess { - send(Result.success(MovieDetailsResult.SimilarSuccess(result.data))) + send(Result.success(MediaDetailsResult.SimilarSuccess(result.data))) } } } @@ -85,7 +85,7 @@ open class GetMovieDetailsUseCase @Inject constructor( .catch { Timber.e(it) } .collect { result -> result.onSuccess { - send(Result.success(MovieDetailsResult.ReviewsSuccess(result.data))) + send(Result.success(MediaDetailsResult.ReviewsSuccess(result.data))) } } } @@ -96,7 +96,7 @@ open class GetMovieDetailsUseCase @Inject constructor( .catch { Timber.e(it) } .collect { result -> result.onSuccess { - send(Result.success(MovieDetailsResult.CreditsSuccess(result.data))) + send(Result.success(MediaDetailsResult.CreditsSuccess(result.data))) } } } @@ -111,7 +111,7 @@ open class GetMovieDetailsUseCase @Inject constructor( } else { result.data.firstOrNull { it.officialTrailer } } - send(Result.success(MovieDetailsResult.VideosSuccess(video))) + send(Result.success(MediaDetailsResult.VideosSuccess(video))) } } @@ -125,7 +125,7 @@ open class GetMovieDetailsUseCase @Inject constructor( .catch { Timber.e(it) } .collect { result -> result.onSuccess { - send(Result.success(MovieDetailsResult.AccountDetailsSuccess(result.data))) + send(Result.success(MediaDetailsResult.AccountDetailsSuccess(result.data))) } } } @@ -135,7 +135,7 @@ open class GetMovieDetailsUseCase @Inject constructor( .catch { Timber.e(it) } .collect { result -> result.onSuccess { - send(Result.success(MovieDetailsResult.MenuOptionsSuccess(result.data))) + send(Result.success(MediaDetailsResult.MenuOptionsSuccess(result.data))) } } } From c9e3a34d5c0fd8e5da69634ed90e3e3cd508e845 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Mon, 22 Jul 2024 21:05:24 +0300 Subject: [PATCH 09/10] test: add tests for credits view model and use case --- .../usecase/AddToWatchlistUseCaseTest.kt | 6 +- .../domain/usecase/DeleteRatingUseCaseTest.kt | 6 +- .../FetchAccountMediaDetailsUseCaseTest.kt | 6 +- .../usecase/GetMediaDetailsUseCaseTest.kt | 6 +- .../domain/usecase/SubmitRatingUseCaseTest.kt | 6 +- .../repository/ProdMoviesRepositoryTest.kt | 4 +- .../repository/ProdDetailsRepositoryTest.kt | 4 +- .../domain/credits/FetchCreditsUseCaseTest.kt | 106 +++++++++++ .../repository/TestDetailsRepository.kt | 9 +- .../core/testing/service/TestMediaService.kt | 4 +- .../usecase/TestFetchCreditsUseCase.kt | 37 ++++ .../credits/ui/CreditsViewModelTest.kt | 175 ++++++++++++++++++ .../credits/ui/CreditsViewModelTestRobot.kt | 48 +++++ .../watchlist/WatchlistViewModelTest.kt | 6 +- 14 files changed, 397 insertions(+), 26 deletions(-) create mode 100644 core/domain/src/test/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCaseTest.kt rename app/src/test/kotlin/com/andreolas/movierama/fakes/repository/FakeDetailsRepository.kt => core/testing/src/main/kotlin/com/divinelink/core/testing/repository/TestDetailsRepository.kt (89%) rename app/src/test/kotlin/com/andreolas/movierama/fakes/remote/FakeMediaService.kt => core/testing/src/main/kotlin/com/divinelink/core/testing/service/TestMediaService.kt (98%) create mode 100644 core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/TestFetchCreditsUseCase.kt create mode 100644 feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTest.kt create mode 100644 feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTestRobot.kt diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/AddToWatchlistUseCaseTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/AddToWatchlistUseCaseTest.kt index 0f28da7b..b429467a 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/AddToWatchlistUseCaseTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/AddToWatchlistUseCaseTest.kt @@ -1,10 +1,10 @@ package com.andreolas.movierama.details.domain.usecase -import com.andreolas.movierama.fakes.repository.FakeDetailsRepository import com.divinelink.core.data.session.model.SessionException import com.divinelink.core.datastore.SessionStorage import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.repository.TestDetailsRepository import com.divinelink.core.testing.storage.FakeEncryptedPreferenceStorage import com.divinelink.core.testing.storage.FakePreferenceStorage import com.divinelink.feature.details.usecase.AddToWatchlistParameters @@ -22,13 +22,13 @@ class AddToWatchlistUseCaseTest { val mainDispatcherRule = MainDispatcherRule() private val testDispatcher = mainDispatcherRule.testDispatcher - private lateinit var repository: FakeDetailsRepository + private lateinit var repository: TestDetailsRepository private lateinit var sessionStorage: SessionStorage @Before fun setUp() { - repository = FakeDetailsRepository() + repository = TestDetailsRepository() } @Test diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/DeleteRatingUseCaseTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/DeleteRatingUseCaseTest.kt index baba6daa..b2b1923b 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/DeleteRatingUseCaseTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/DeleteRatingUseCaseTest.kt @@ -1,11 +1,11 @@ package com.andreolas.movierama.details.domain.usecase -import com.andreolas.movierama.fakes.repository.FakeDetailsRepository import com.divinelink.core.commons.domain.data import com.divinelink.core.data.session.model.SessionException import com.divinelink.core.datastore.SessionStorage import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.repository.TestDetailsRepository import com.divinelink.core.testing.storage.FakeEncryptedPreferenceStorage import com.divinelink.core.testing.storage.FakePreferenceStorage import com.divinelink.feature.details.usecase.DeleteRatingParameters @@ -23,13 +23,13 @@ class DeleteRatingUseCaseTest { val mainDispatcherRule = MainDispatcherRule() private val testDispatcher = mainDispatcherRule.testDispatcher - private lateinit var repository: FakeDetailsRepository + private lateinit var repository: TestDetailsRepository private lateinit var sessionStorage: SessionStorage @Before fun setUp() { - repository = FakeDetailsRepository() + repository = TestDetailsRepository() } @Test diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/FetchAccountMediaDetailsUseCaseTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/FetchAccountMediaDetailsUseCaseTest.kt index 8e40da5b..599f95a3 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/FetchAccountMediaDetailsUseCaseTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/FetchAccountMediaDetailsUseCaseTest.kt @@ -1,13 +1,13 @@ package com.andreolas.movierama.details.domain.usecase import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory -import com.andreolas.movierama.fakes.repository.FakeDetailsRepository import com.divinelink.core.commons.domain.data import com.divinelink.core.data.details.model.MediaDetailsParams import com.divinelink.core.data.session.model.SessionException import com.divinelink.core.datastore.SessionStorage import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.repository.TestDetailsRepository import com.divinelink.core.testing.storage.FakeEncryptedPreferenceStorage import com.divinelink.core.testing.storage.FakePreferenceStorage import com.google.common.truth.Truth.assertThat @@ -23,13 +23,13 @@ class FetchAccountMediaDetailsUseCaseTest { val mainDispatcherRule = MainDispatcherRule() private val testDispatcher = mainDispatcherRule.testDispatcher - private lateinit var repository: FakeDetailsRepository + private lateinit var repository: TestDetailsRepository private lateinit var sessionStorage: SessionStorage @Before fun setUp() { - repository = FakeDetailsRepository() + repository = TestDetailsRepository() } @Test diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt index 905daf50..8c9f0e1b 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/GetMediaDetailsUseCaseTest.kt @@ -3,7 +3,6 @@ package com.andreolas.movierama.details.domain.usecase import app.cash.turbine.test import com.andreolas.factories.VideoFactory import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory -import com.andreolas.movierama.fakes.repository.FakeDetailsRepository import com.andreolas.movierama.fakes.repository.FakeMoviesRepository import com.andreolas.movierama.fakes.usecase.details.FakeFetchAccountMediaDetailsUseCase import com.divinelink.core.data.details.model.MediaDetailsException @@ -20,6 +19,7 @@ import com.divinelink.core.network.media.model.details.DetailsRequestApi import com.divinelink.core.network.media.model.details.similar.SimilarRequestApi import com.divinelink.core.testing.MainDispatcherRule import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory +import com.divinelink.core.testing.repository.TestDetailsRepository import com.divinelink.core.testing.usecase.FakeGetDropdownMenuItemsUseCase import com.divinelink.feature.details.ui.MediaDetailsResult import com.divinelink.feature.details.usecase.GetMediaDetailsUseCase @@ -38,7 +38,7 @@ class GetMediaDetailsUseCaseTest { val mainDispatcherRule = MainDispatcherRule() private val testDispatcher = mainDispatcherRule.testDispatcher - private lateinit var repository: FakeDetailsRepository + private lateinit var repository: TestDetailsRepository private lateinit var moviesRepository: FakeMoviesRepository private lateinit var fakeFetchAccountMediaDetailsUseCase: FakeFetchAccountMediaDetailsUseCase @@ -82,7 +82,7 @@ class GetMediaDetailsUseCaseTest { @Before fun setUp() { - repository = FakeDetailsRepository() + repository = TestDetailsRepository() moviesRepository = FakeMoviesRepository() fakeFetchAccountMediaDetailsUseCase = FakeFetchAccountMediaDetailsUseCase() fakeGetDropdownMenuItemsUseCase = FakeGetDropdownMenuItemsUseCase() diff --git a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/SubmitRatingUseCaseTest.kt b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/SubmitRatingUseCaseTest.kt index 1d9a2093..e68626ee 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/SubmitRatingUseCaseTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/details/domain/usecase/SubmitRatingUseCaseTest.kt @@ -1,11 +1,11 @@ package com.andreolas.movierama.details.domain.usecase -import com.andreolas.movierama.fakes.repository.FakeDetailsRepository import com.divinelink.core.commons.domain.data import com.divinelink.core.data.session.model.SessionException import com.divinelink.core.datastore.SessionStorage import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.repository.TestDetailsRepository import com.divinelink.core.testing.storage.FakeEncryptedPreferenceStorage import com.divinelink.core.testing.storage.FakePreferenceStorage import com.google.common.truth.Truth.assertThat @@ -21,13 +21,13 @@ class SubmitRatingUseCaseTest { val mainDispatcherRule = MainDispatcherRule() private val testDispatcher = mainDispatcherRule.testDispatcher - private lateinit var repository: FakeDetailsRepository + private lateinit var repository: TestDetailsRepository private lateinit var sessionStorage: SessionStorage @Before fun setUp() { - repository = FakeDetailsRepository() + repository = TestDetailsRepository() } @Test diff --git a/app/src/test/kotlin/com/andreolas/movierama/popular/domain/repository/ProdMoviesRepositoryTest.kt b/app/src/test/kotlin/com/andreolas/movierama/popular/domain/repository/ProdMoviesRepositoryTest.kt index a7124260..d8fc7f58 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/popular/domain/repository/ProdMoviesRepositoryTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/popular/domain/repository/ProdMoviesRepositoryTest.kt @@ -2,7 +2,6 @@ package com.andreolas.movierama.popular.domain.repository import com.andreolas.factories.api.SearchMovieApiFactory import com.andreolas.movierama.fakes.dao.FakeMediaDao -import com.andreolas.movierama.fakes.remote.FakeMediaService import com.divinelink.core.commons.domain.data import com.divinelink.core.data.media.repository.MediaRepository import com.divinelink.core.data.media.repository.ProdMediaRepository @@ -15,6 +14,7 @@ import com.divinelink.core.network.media.model.search.movie.SearchResponseApi import com.divinelink.core.testing.factories.api.movie.MovieApiFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory.toWizard +import com.divinelink.core.testing.service.TestMediaService import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -53,7 +53,7 @@ class ProdMoviesRepositoryTest { ) private var mediaDao = FakeMediaDao() - private var mediaRemote = FakeMediaService() + private var mediaRemote = TestMediaService() private lateinit var repository: MediaRepository diff --git a/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt b/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt index 9949b63d..43cebc06 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt +++ b/app/src/test/kotlin/com/andreolas/movierama/search/domain/repository/ProdDetailsRepositoryTest.kt @@ -8,7 +8,6 @@ import com.andreolas.factories.api.ReviewsResultsApiFactory import com.andreolas.factories.api.SimilarMovieApiFactory import com.andreolas.factories.api.account.states.AccountMediaDetailsResponseApiFactory import com.andreolas.factories.details.domain.model.account.AccountMediaDetailsFactory -import com.andreolas.movierama.fakes.remote.FakeMediaService import com.divinelink.core.commons.domain.data import com.divinelink.core.data.details.model.MediaDetailsException import com.divinelink.core.data.details.model.ReviewsException @@ -40,6 +39,7 @@ import com.divinelink.core.testing.factories.entity.credits.AggregateCreditsEnti import com.divinelink.core.testing.factories.model.details.MediaDetailsFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory.toWizard +import com.divinelink.core.testing.service.TestMediaService import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -135,7 +135,7 @@ class ProdDetailsRepositoryTest { ) } - private var mediaRemote = FakeMediaService() + private var mediaRemote = TestMediaService() private var creditsDao = TestCreditsDao() private lateinit var repository: DetailsRepository diff --git a/core/domain/src/test/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCaseTest.kt b/core/domain/src/test/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCaseTest.kt new file mode 100644 index 00000000..ddd9a988 --- /dev/null +++ b/core/domain/src/test/kotlin/com/divinelink/core/domain/credits/FetchCreditsUseCaseTest.kt @@ -0,0 +1,106 @@ +package com.divinelink.core.domain.credits + +import app.cash.turbine.test +import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory +import com.divinelink.core.testing.repository.TestDetailsRepository +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import kotlin.test.Test + +class FetchCreditsUseCaseTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + private val testDispatcher = mainDispatcherRule.testDispatcher + + private var repository: TestDetailsRepository = TestDetailsRepository() + + @Test + fun `test fetch credits use case`() = runTest { + repository.mockFetchAggregateCredits( + response = Result.success( + AggregatedCreditsFactory.credits(), + ), + ) + val useCase = FetchCreditsUseCase( + repository = repository.mock, + dispatcher = testDispatcher, + ) + + useCase(AggregatedCreditsFactory.credits().id).test { + awaitItem().let { result -> + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(AggregatedCreditsFactory.credits()) + } + awaitComplete() + } + } + + @Test + fun `test multiple emissions from use case`() = runTest { + val castChunked = AggregatedCreditsFactory.credits().cast.chunked(3) + val crew = AggregatedCreditsFactory.credits().crewDepartments + + repository.mockFetchAggregateCredits( + response = flowOf( + Result.success( + AggregatedCreditsFactory.credits().copy( + cast = castChunked[0], + crewDepartments = crew, + ), + ), + Result.success( + AggregatedCreditsFactory.credits().copy( + cast = castChunked[0] + castChunked[1], + crewDepartments = crew, + ), + ), + Result.success( + AggregatedCreditsFactory.credits().copy( + cast = castChunked[0] + castChunked[1] + castChunked[2], + crewDepartments = crew, + ), + ), + ), + ) + + val useCase = FetchCreditsUseCase( + repository = repository.mock, + dispatcher = testDispatcher, + ) + + useCase(AggregatedCreditsFactory.credits().id).test { + awaitItem().let { result -> + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo( + AggregatedCreditsFactory.credits().copy( + cast = castChunked[0], + crewDepartments = crew, + ), + ) + } + awaitItem().let { result -> + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo( + AggregatedCreditsFactory.credits().copy( + cast = castChunked[0] + castChunked[1], + crewDepartments = crew, + ), + ) + } + awaitItem().let { result -> + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo( + AggregatedCreditsFactory.credits().copy( + cast = castChunked[0] + castChunked[1] + castChunked[2], + crewDepartments = crew, + ), + ) + } + awaitComplete() + } + } +} diff --git a/app/src/test/kotlin/com/andreolas/movierama/fakes/repository/FakeDetailsRepository.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/repository/TestDetailsRepository.kt similarity index 89% rename from app/src/test/kotlin/com/andreolas/movierama/fakes/repository/FakeDetailsRepository.kt rename to core/testing/src/main/kotlin/com/divinelink/core/testing/repository/TestDetailsRepository.kt index 35a56190..a739808e 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/fakes/repository/FakeDetailsRepository.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/repository/TestDetailsRepository.kt @@ -1,4 +1,4 @@ -package com.andreolas.movierama.fakes.repository +package com.divinelink.core.testing.repository import com.divinelink.core.data.details.repository.DetailsRepository import com.divinelink.core.model.account.AccountMediaDetails @@ -9,12 +9,13 @@ import com.divinelink.core.model.details.video.Video import com.divinelink.core.model.media.MediaItem import com.divinelink.core.network.media.model.details.DetailsRequestApi import com.divinelink.core.network.media.model.details.similar.SimilarRequestApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class FakeDetailsRepository { +class TestDetailsRepository { val mock: DetailsRepository = mock() @@ -70,6 +71,10 @@ class FakeDetailsRepository { ) } + fun mockFetchAggregateCredits(response: Flow>) { + whenever(mock.fetchAggregateCredits(any())).thenReturn(response) + } + fun mockFetchAccountMediaDetails(response: Result) { whenever( mock.fetchAccountMediaDetails(any()), diff --git a/app/src/test/kotlin/com/andreolas/movierama/fakes/remote/FakeMediaService.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/service/TestMediaService.kt similarity index 98% rename from app/src/test/kotlin/com/andreolas/movierama/fakes/remote/FakeMediaService.kt rename to core/testing/src/main/kotlin/com/divinelink/core/testing/service/TestMediaService.kt index 70b8b3dc..9e6dc99e 100644 --- a/app/src/test/kotlin/com/andreolas/movierama/fakes/remote/FakeMediaService.kt +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/service/TestMediaService.kt @@ -1,4 +1,4 @@ -package com.andreolas.movierama.fakes.remote +package com.divinelink.core.testing.service import com.divinelink.core.network.media.model.credits.AggregateCreditsApi import com.divinelink.core.network.media.model.details.DetailsRequestApi @@ -23,7 +23,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -class FakeMediaService { +class TestMediaService { val mock: MediaService = mock() diff --git a/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/TestFetchCreditsUseCase.kt b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/TestFetchCreditsUseCase.kt new file mode 100644 index 00000000..32568d8b --- /dev/null +++ b/core/testing/src/main/kotlin/com/divinelink/core/testing/usecase/TestFetchCreditsUseCase.kt @@ -0,0 +1,37 @@ +package com.divinelink.core.testing.usecase + +import com.divinelink.core.domain.credits.FetchCreditsUseCase +import com.divinelink.core.model.credits.AggregateCredits +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.flowOf +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class TestFetchCreditsUseCase { + + val mock: FetchCreditsUseCase = mock() + + init { + mockFailure() + } + + private fun mockFailure() { + whenever( + mock.invoke(any()), + ).thenReturn( + flowOf(Result.failure(Exception())), + ) + } + + fun mockSuccess(response: Flow>) { + whenever(mock.invoke(any())).thenReturn(response) + } + + // Mock multiple emissions + fun mockSuccess(response: Channel>) { + whenever(mock.invoke(any())).thenReturn(response.consumeAsFlow()) + } +} diff --git a/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTest.kt b/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTest.kt new file mode 100644 index 00000000..64a5fb7b --- /dev/null +++ b/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTest.kt @@ -0,0 +1,175 @@ +package com.divinelink.feature.credits.ui + +import com.divinelink.core.model.credits.AggregateCredits +import com.divinelink.core.model.media.MediaType +import com.divinelink.core.testing.MainDispatcherRule +import com.divinelink.core.testing.assertUiState +import com.divinelink.core.testing.expectUiStates +import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory +import com.divinelink.feature.credits.navigation.CreditsNavArguments +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class CreditsViewModelTest { + + private var robot: CreditsViewModelTestRobot = CreditsViewModelTestRobot() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test initialise viewModel`() = runTest { + robot + .withNavArgs( + CreditsNavArguments( + id = AggregatedCreditsFactory.credits().id, + mediaType = MediaType.TV, + ), + ) + .buildViewModel() + .assertUiState(CreditsUiState.initial()) + } + + @Test + fun `test fetchCredits from useCase`() = runTest { + robot + .withNavArgs( + CreditsNavArguments( + id = AggregatedCreditsFactory.credits().id, + mediaType = MediaType.TV, + ), + ) + .mockFetchCreditsUseCaseSuccess(flowOf(Result.success(AggregatedCreditsFactory.credits()))) + .buildViewModel() + .assertUiState( + CreditsUiState( + forms = mapOf( + CreditsTab.Cast(8) to CreditsUiContent.Cast(AggregatedCreditsFactory.credits().cast), + CreditsTab.Crew(12) to + CreditsUiContent.Crew(AggregatedCreditsFactory.credits().crewDepartments), + ), + tabs = listOf( + CreditsTab.Cast(8), + CreditsTab.Crew(12), + ), + selectedTabIndex = 0, + ), + ) + } + + @Test + fun `test flow emissions from useCase`() = runTest { + val castChunked = AggregatedCreditsFactory.credits().cast.chunked(3) + val crewChunked = AggregatedCreditsFactory.credits().crewDepartments + + // Set up a channel to emit multiple values + val channel = Channel>() + + robot + .withNavArgs( + CreditsNavArguments( + id = AggregatedCreditsFactory.credits().id, + mediaType = MediaType.TV, + ), + ) + .setupChannelForUseCase(channel) + .buildViewModel() + .expectUiStates( + action = { + // Send the first emission + launch { + channel.send( + Result.success(AggregatedCreditsFactory.credits().copy(cast = castChunked[0])), + ) + } + // Send the second emission + launch { + channel.send( + Result.success( + AggregatedCreditsFactory.credits().copy(cast = castChunked[0] + castChunked[1]), + ), + ) + } + // Send the third emission + launch { + channel.send( + Result.success( + AggregatedCreditsFactory.credits() + .copy(cast = castChunked[0] + castChunked[1] + castChunked[2]), + ), + ) + } + }, + uiStates = listOf( + CreditsUiState.initial(), + CreditsUiState( + forms = mapOf( + CreditsTab.Cast(3) to CreditsUiContent.Cast(castChunked[0]), + CreditsTab.Crew(12) to CreditsUiContent.Crew(crewChunked), + ), + tabs = listOf( + CreditsTab.Cast(3), + CreditsTab.Crew(12), + ), + selectedTabIndex = 0, + ), + CreditsUiState( + forms = mapOf( + CreditsTab.Cast(6) to CreditsUiContent.Cast(castChunked[0] + castChunked[1]), + CreditsTab.Crew(12) to CreditsUiContent.Crew(crewChunked), + ), + tabs = listOf( + CreditsTab.Cast(6), + CreditsTab.Crew(12), + ), + selectedTabIndex = 0, + ), + CreditsUiState( + forms = mapOf( + CreditsTab.Cast(8) to CreditsUiContent.Cast( + castChunked[0] + castChunked[1] + castChunked[2], + ), + CreditsTab.Crew(12) to CreditsUiContent.Crew(crewChunked), + ), + tabs = listOf( + CreditsTab.Cast(8), + CreditsTab.Crew(12), + ), + selectedTabIndex = 0, + ), + ), + ) + } + + @Test + fun `test onTabSelected`() = runTest { + robot + .withNavArgs( + CreditsNavArguments( + id = AggregatedCreditsFactory.credits().id, + mediaType = MediaType.TV, + ), + ) + .mockFetchCreditsUseCaseSuccess(flowOf(Result.success(AggregatedCreditsFactory.credits()))) + .buildViewModel() + .onTabSelected(1) + .assertUiState( + CreditsUiState( + forms = mapOf( + CreditsTab.Cast(8) to CreditsUiContent.Cast(AggregatedCreditsFactory.credits().cast), + CreditsTab.Crew(12) to + CreditsUiContent.Crew(AggregatedCreditsFactory.credits().crewDepartments), + ), + tabs = listOf( + CreditsTab.Cast(8), + CreditsTab.Crew(12), + ), + selectedTabIndex = 1, + ), + ) + } +} diff --git a/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTestRobot.kt b/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTestRobot.kt new file mode 100644 index 00000000..4a24a68d --- /dev/null +++ b/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsViewModelTestRobot.kt @@ -0,0 +1,48 @@ +package com.divinelink.feature.credits.ui + +import androidx.lifecycle.SavedStateHandle +import com.divinelink.core.model.credits.AggregateCredits +import com.divinelink.core.testing.ViewModelTestRobot +import com.divinelink.core.testing.usecase.TestFetchCreditsUseCase +import com.divinelink.feature.credits.navigation.CreditsNavArguments +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow + +class CreditsViewModelTestRobot : ViewModelTestRobot() { + + private val fetchCreditsUseCase = TestFetchCreditsUseCase() + + private lateinit var viewModel: CreditsViewModel + private lateinit var navArgs: CreditsNavArguments + + override val actualUiState: Flow + get() = viewModel.uiState + + override fun buildViewModel() = apply { + viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = SavedStateHandle( + mapOf( + "id" to navArgs.id, + "mediaType" to navArgs.mediaType, + ), + ), + ) + } + + fun withNavArgs(navArgs: CreditsNavArguments) = apply { + this.navArgs = navArgs + } + + fun mockFetchCreditsUseCaseSuccess(result: Flow>) = apply { + fetchCreditsUseCase.mockSuccess(result) + } + + fun setupChannelForUseCase(result: Channel>) = apply { + fetchCreditsUseCase.mockSuccess(result) + } + + fun onTabSelected(tabIndex: Int) = apply { + viewModel.onTabSelected(tabIndex) + } +} diff --git a/feature/watchlist/src/test/kotlin/com/divinelink/feature/watchlist/WatchlistViewModelTest.kt b/feature/watchlist/src/test/kotlin/com/divinelink/feature/watchlist/WatchlistViewModelTest.kt index 98e4d95f..0e264936 100644 --- a/feature/watchlist/src/test/kotlin/com/divinelink/feature/watchlist/WatchlistViewModelTest.kt +++ b/feature/watchlist/src/test/kotlin/com/divinelink/feature/watchlist/WatchlistViewModelTest.kt @@ -162,11 +162,11 @@ class WatchlistViewModelTest { .buildViewModel() .expectUiStates( action = { - testRobot.selectTab(1) - testRobot.mockFetchWatchlist { + selectTab(1) + mockFetchWatchlist { mockSuccess(Result.success(WatchlistResponseFactory.tv())) } - testRobot.onLoadMore() + onLoadMore() }, uiStates = expectedUiStates, ) From 70d306b1be67e9aea26ea440aaeb47e7ae09b110 Mon Sep 17 00:00:00 2001 From: Harry Andreolas Date: Tue, 23 Jul 2024 18:08:37 +0300 Subject: [PATCH 10/10] test: add ui tests for credits --- .../ui/details/DetailsContentTest.kt | 2 +- .../andreolas/ui/details/DetailsScreenTest.kt | 165 ++++++++++++++- .../com/divinelink/core/ui/EmptyContent.kt | 15 +- .../kotlin/com/divinelink/core/ui/TestTags.kt | 4 +- .../components/details/cast/CreatorsItem.kt | 1 + docs/RootNavGraph.html | 12 +- docs/RootNavGraph.mmd | 10 +- .../feature/credits/ui/CreditsContent.kt | 194 +++++++++-------- .../feature/credits/ui/CreditsScreen.kt | 1 - .../feature/credits/ui/CreditsUiContent.kt | 11 +- .../credits/src/main/res/values/strings.xml | 3 + .../feature/credits/ui/CreditsScreenTest.kt | 198 ++++++++++++++++++ .../feature/watchlist/WatchlistScreen.kt | 3 +- 13 files changed, 495 insertions(+), 124 deletions(-) rename feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistEmptyContent.kt => core/ui/src/main/kotlin/com/divinelink/core/ui/EmptyContent.kt (75%) create mode 100644 feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsScreenTest.kt diff --git a/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt b/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt index 5618b867..f6d4fbbc 100644 --- a/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt +++ b/app/src/test/kotlin/com/andreolas/ui/details/DetailsContentTest.kt @@ -29,7 +29,7 @@ import com.divinelink.feature.details.ui.DetailsViewState import com.divinelink.feature.details.ui.MOVIE_DETAILS_SCROLLABLE_LIST_TAG import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest -import org.junit.Test +import kotlin.test.Test import com.divinelink.core.ui.R as uiR import com.divinelink.feature.details.R as detailsR diff --git a/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt b/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt index e9427a8c..08dab5e6 100644 --- a/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt +++ b/app/src/test/kotlin/com/andreolas/ui/details/DetailsScreenTest.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeRight @@ -19,17 +20,23 @@ import com.andreolas.movierama.fakes.usecase.details.FakeFetchAccountMediaDetail import com.andreolas.movierama.fakes.usecase.details.FakeSubmitRatingUseCase import com.divinelink.core.model.media.MediaType import com.divinelink.core.testing.ComposeTest +import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory import com.divinelink.core.testing.factories.model.details.MediaDetailsFactory import com.divinelink.core.testing.factories.model.media.MediaItemFactory +import com.divinelink.core.testing.getString import com.divinelink.core.testing.navigator.FakeDestinationsNavigator import com.divinelink.core.testing.setContentWithTheme -import com.divinelink.core.testing.usecase.FakeGetDropdownMenuItemsUseCase import com.divinelink.core.testing.usecase.FakeRequestMediaUseCase +import com.divinelink.core.ui.R import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.components.details.similar.SIMILAR_MOVIES_SCROLLABLE_LIST +import com.divinelink.feature.credits.navigation.CreditsNavArguments +import com.divinelink.feature.credits.screens.destinations.CreditsScreenDestination import com.divinelink.feature.details.screens.destinations.DetailsScreenDestination import com.divinelink.feature.details.ui.DetailsNavArguments import com.divinelink.feature.details.ui.DetailsScreen +import com.divinelink.feature.details.ui.DetailsViewModel +import com.divinelink.feature.details.ui.MOVIE_DETAILS_SCROLLABLE_LIST_TAG import com.divinelink.feature.details.ui.MediaDetailsResult import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -48,7 +55,6 @@ class DetailsScreenTest : ComposeTest() { val deleteRatingUseCase = FakeDeleteRatingUseCase() val addToWatchlistUseCase = FakeAddToWatchlistUseCase() val requestMediaUseCase = FakeRequestMediaUseCase() - val getMenuItemsUseCase = FakeGetDropdownMenuItemsUseCase() val destinationsNavigator = FakeDestinationsNavigator() destinationsNavigator.navigate( @@ -85,7 +91,7 @@ class DetailsScreenTest : ComposeTest() { setContentWithTheme { DetailsScreen( navigator = destinationsNavigator, - viewModel = com.divinelink.feature.details.ui.DetailsViewModel( + viewModel = DetailsViewModel( getMediaDetailsUseCase = getMovieDetailsUseCase.mock, onMarkAsFavoriteUseCase = markAsFavoriteUseCase, submitRatingUseCase = submitRateUseCase.mock, @@ -104,7 +110,7 @@ class DetailsScreenTest : ComposeTest() { } composeTestRule - .onNodeWithTag(com.divinelink.feature.details.ui.MOVIE_DETAILS_SCROLLABLE_LIST_TAG) + .onNodeWithTag(MOVIE_DETAILS_SCROLLABLE_LIST_TAG) .performScrollToNode( matcher = hasText( MediaItemFactory.MoviesList()[0].name, @@ -176,7 +182,7 @@ class DetailsScreenTest : ComposeTest() { ), ) - val viewModel = com.divinelink.feature.details.ui.DetailsViewModel( + val viewModel = DetailsViewModel( getMediaDetailsUseCase = getMovieDetailsUseCase.mock, onMarkAsFavoriteUseCase = markAsFavoriteUseCase, submitRatingUseCase = submitRateUseCase.mock, @@ -218,7 +224,6 @@ class DetailsScreenTest : ComposeTest() { val deleteRatingUseCase = FakeDeleteRatingUseCase() val addToWatchlistUseCase = FakeAddToWatchlistUseCase() val requestMediaUseCase = FakeRequestMediaUseCase() - val getMenuItemsUseCase = FakeGetDropdownMenuItemsUseCase() val destinationsNavigator = FakeDestinationsNavigator() fetchAccountMediaDetailsUseCase.mockFetchAccountDetails( @@ -239,7 +244,7 @@ class DetailsScreenTest : ComposeTest() { ), ) - val viewModel = com.divinelink.feature.details.ui.DetailsViewModel( + val viewModel = DetailsViewModel( getMediaDetailsUseCase = getMovieDetailsUseCase.mock, onMarkAsFavoriteUseCase = markAsFavoriteUseCase, submitRatingUseCase = submitRateUseCase.mock, @@ -294,4 +299,150 @@ class DetailsScreenTest : ComposeTest() { useUnmergedTree = true, ).assertIsDisplayed() } + + @Test + fun `test navigate to credits screen with tv credits`() { + val getMovieDetailsUseCase = FakeGetMediaDetailsUseCase() + val markAsFavoriteUseCase = FakeMarkAsFavoriteUseCase() + val submitRateUseCase = FakeSubmitRatingUseCase() + val deleteRatingUseCase = FakeDeleteRatingUseCase() + val addToWatchlistUseCase = FakeAddToWatchlistUseCase() + val requestMediaUseCase = FakeRequestMediaUseCase() + val destinationsNavigator = FakeDestinationsNavigator() + + // Initial navigation to Details screen + destinationsNavigator.navigate( + direction = DetailsScreenDestination( + DetailsNavArguments( + id = 2316, + mediaType = MediaType.TV.name, + isFavorite = false, + ), + ), + ) + + getMovieDetailsUseCase.mockFetchMediaDetails( + response = flowOf( + Result.success( + MediaDetailsResult.DetailsSuccess( + mediaDetails = MediaDetailsFactory.TheOffice(), + ), + ), + Result.success( + MediaDetailsResult.CreditsSuccess( + aggregateCredits = AggregatedCreditsFactory.credits(), + ), + ), + ), + ) + + setContentWithTheme { + DetailsScreen( + navigator = destinationsNavigator, + viewModel = DetailsViewModel( + getMediaDetailsUseCase = getMovieDetailsUseCase.mock, + onMarkAsFavoriteUseCase = markAsFavoriteUseCase, + submitRatingUseCase = submitRateUseCase.mock, + deleteRatingUseCase = deleteRatingUseCase.mock, + addToWatchlistUseCase = addToWatchlistUseCase.mock, + requestMediaUseCase = requestMediaUseCase.mock, + savedStateHandle = SavedStateHandle( + mapOf( + "id" to 2316, + "isFavorite" to false, + "mediaType" to MediaType.TV.value, + ), + ), + ), + ) + } + + with(composeTestRule) { + onNodeWithText(getString(R.string.core_ui_view_all)) + .performScrollTo() + .assertIsDisplayed() + .performClick() + + destinationsNavigator.verifyNavigatedToDirection( + expectedDirection = CreditsScreenDestination( + CreditsNavArguments( + id = 2316, + mediaType = MediaType.TV, + ), + ), + ) + + // Navigate up from Credits screen + onNodeWithContentDescription( + getString(uiR.string.core_ui_navigate_up_button_content_description), + ).assertIsDisplayed().performClick() + + destinationsNavigator.verifyNavigatedToDirection( + expectedDirection = DetailsScreenDestination( + DetailsNavArguments( + id = 2316, + isFavorite = false, + mediaType = MediaType.TV.name, + ), + ), + ) + } + } + + @Test + fun `test viewAll credits does not exist without tv credits`() { + val getMovieDetailsUseCase = FakeGetMediaDetailsUseCase() + val markAsFavoriteUseCase = FakeMarkAsFavoriteUseCase() + val submitRateUseCase = FakeSubmitRatingUseCase() + val deleteRatingUseCase = FakeDeleteRatingUseCase() + val addToWatchlistUseCase = FakeAddToWatchlistUseCase() + val requestMediaUseCase = FakeRequestMediaUseCase() + val destinationsNavigator = FakeDestinationsNavigator() + + // Initial navigation to Details screen + destinationsNavigator.navigate( + direction = DetailsScreenDestination( + DetailsNavArguments( + id = 2316, + mediaType = MediaType.TV.name, + isFavorite = false, + ), + ), + ) + + getMovieDetailsUseCase.mockFetchMediaDetails( + response = flowOf( + Result.success( + MediaDetailsResult.DetailsSuccess( + mediaDetails = MediaDetailsFactory.TheOffice(), + ), + ), + ), + ) + + setContentWithTheme { + DetailsScreen( + navigator = destinationsNavigator, + viewModel = DetailsViewModel( + getMediaDetailsUseCase = getMovieDetailsUseCase.mock, + onMarkAsFavoriteUseCase = markAsFavoriteUseCase, + submitRatingUseCase = submitRateUseCase.mock, + deleteRatingUseCase = deleteRatingUseCase.mock, + addToWatchlistUseCase = addToWatchlistUseCase.mock, + requestMediaUseCase = requestMediaUseCase.mock, + savedStateHandle = SavedStateHandle( + mapOf( + "id" to 2316, + "isFavorite" to false, + "mediaType" to MediaType.TV.value, + ), + ), + ), + ) + } + + with(composeTestRule) { + onNodeWithText(getString(R.string.core_ui_view_all)).assertDoesNotExist() + } + } } diff --git a/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistEmptyContent.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/EmptyContent.kt similarity index 75% rename from feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistEmptyContent.kt rename to core/ui/src/main/kotlin/com/divinelink/core/ui/EmptyContent.kt index 28129768..2c525919 100644 --- a/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistEmptyContent.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/EmptyContent.kt @@ -1,4 +1,4 @@ -package com.divinelink.feature.watchlist +package com.divinelink.core.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -10,17 +10,16 @@ import androidx.compose.material3.Text 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.text.style.TextAlign import com.divinelink.core.designsystem.theme.AppTheme import com.divinelink.core.designsystem.theme.dimensions -import com.divinelink.core.ui.Previews -import com.divinelink.core.ui.UIText -import com.divinelink.core.ui.getString @Composable -fun WatchlistEmptyContent(text: UIText) { +fun EmptyContent(text: UIText) { Column( modifier = Modifier + .testTag(TestTags.BLANK_SLATE) .fillMaxSize() .padding(horizontal = MaterialTheme.dimensions.keyline_16), verticalArrangement = Arrangement.Center, @@ -36,11 +35,11 @@ fun WatchlistEmptyContent(text: UIText) { @Previews @Composable -private fun WatchlistContentPreview() { +private fun CreditsEmptyContentPreview() { AppTheme { Surface { - WatchlistEmptyContent( - UIText.ResourceText(R.string.feature_watchlist_empty_movies_watchlist), + EmptyContent( + UIText.StringText("No credits available"), ) } } diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt index 4ab27d0e..b83f552e 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/TestTags.kt @@ -6,6 +6,7 @@ object TestTags { const val MOVIES_LIST_TAG = "MOVIES_LIST_TAG" const val SCROLL_TO_TOP_BUTTON = "SCROLL_TO_TOP_BUTTON_TAG" const val LOADING_PROGRESS = "Loading Progress Bar" + const val BLANK_SLATE = "Blank Slate" const val LAZY_COLUMN = "Lazy Column" @@ -76,6 +77,7 @@ object TestTags { object Credits { const val TAB_BAR = "Credits Tab Bar $%s" - const val CREDITS_CONTENT = "Credits Content with data" + const val CAST_CREDITS_CONTENT = "Credits Content with cast" + const val CREW_CREDITS_CONTENT = "Credits Content with crew" } } diff --git a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CreatorsItem.kt b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CreatorsItem.kt index bb457691..844b4f57 100644 --- a/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CreatorsItem.kt +++ b/core/ui/src/main/kotlin/com/divinelink/core/ui/components/details/cast/CreatorsItem.kt @@ -25,6 +25,7 @@ import com.divinelink.core.model.details.Person import com.divinelink.core.ui.Previews import com.divinelink.core.ui.R +// TODO Add UI Tests @Composable fun CreatorsItem(creators: List?) { if (creators.isNullOrEmpty()) return diff --git a/docs/RootNavGraph.html b/docs/RootNavGraph.html index 0a22092e..162009fa 100644 --- a/docs/RootNavGraph.html +++ b/docs/RootNavGraph.html @@ -2,23 +2,23 @@ - Watchlist/root Navigation Graph + Featurecredits/root Navigation Graph
 ---
-title: Watchlist/root Navigation Graph
+title: Featurecredits/root Navigation Graph
 ---
 %%{init: {'theme':'base', 'themeVariables': { 'primaryTextColor': '#fff' }}%%
 graph TD
-watchlist/root(["RootGraph"]) -- "start" --- watchlist/watchlist(["WatchlistGraph"])
-watchlist/watchlist(["WatchlistGraph"]) -- "start" --- watchlist/watchlist_screen("WatchlistScreen")
+featurecredits/root(["RootGraph"]) -- "start" --- featurecredits/credits(["CreditsGraph"])
+featurecredits/credits(["CreditsGraph"]) -- "start" --- featurecredits/credits_screen("CreditsScreen")
 
 
 classDef destination fill:#5383EC,stroke:#ffffff;
-class watchlist/watchlist_screen destination;
+class featurecredits/credits_screen destination;
 classDef navgraph fill:#63BC76,stroke:#ffffff;
-class watchlist/watchlist,watchlist/root,watchlist/watchlist navgraph;
+class featurecredits/credits,featurecredits/root,featurecredits/credits navgraph;
 
 
diff --git a/docs/RootNavGraph.mmd b/docs/RootNavGraph.mmd index a60dd016..585507ed 100644 --- a/docs/RootNavGraph.mmd +++ b/docs/RootNavGraph.mmd @@ -1,13 +1,13 @@ --- -title: Watchlist/root Navigation Graph +title: Featurecredits/root Navigation Graph --- %%{init: {'theme':'base', 'themeVariables': { 'primaryTextColor': '#fff' }}%% graph TD -watchlist/root(["RootGraph"]) -- "start" --- watchlist/watchlist(["WatchlistGraph"]) -watchlist/watchlist(["WatchlistGraph"]) -- "start" --- watchlist/watchlist_screen("WatchlistScreen") +featurecredits/root(["RootGraph"]) -- "start" --- featurecredits/credits(["CreditsGraph"]) +featurecredits/credits(["CreditsGraph"]) -- "start" --- featurecredits/credits_screen("CreditsScreen") classDef destination fill:#5383EC,stroke:#ffffff; -class watchlist/watchlist_screen destination; +class featurecredits/credits_screen destination; classDef navgraph fill:#63BC76,stroke:#ffffff; -class watchlist/watchlist,watchlist/root,watchlist/watchlist navgraph; +class featurecredits/credits,featurecredits/root,featurecredits/credits navgraph; diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt index 442a8c03..5c8a1a62 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsContent.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -27,6 +28,7 @@ import com.divinelink.core.designsystem.theme.dimensions import com.divinelink.core.model.credits.PersonRole import com.divinelink.core.model.credits.SeriesCrewDepartment import com.divinelink.core.model.details.Person +import com.divinelink.core.ui.EmptyContent import com.divinelink.core.ui.Previews import com.divinelink.core.ui.TestTags import kotlinx.coroutines.launch @@ -69,51 +71,18 @@ fun CreditsContent( when (val content = state.forms.values.elementAt(page)) { is CreditsUiContent.Cast -> { if (content.cast.isEmpty()) { - return@HorizontalPager - } - LazyColumn( - modifier = Modifier.testTag(TestTags.Credits.CREDITS_CONTENT), - contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), - ) { - items( - items = content.cast, - key = { it.id }, - ) { person -> - PersonItem( - person = person, - onClick = onPersonSelected, - ) - } - } - } - is CreditsUiContent.Crew -> { - if (content.crew.isEmpty()) { - return@HorizontalPager - } - LazyColumn( - modifier = Modifier.testTag(TestTags.Credits.CREDITS_CONTENT), - contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), - verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), - ) { - content.crew.forEach { department -> - item { - Text( - modifier = Modifier.padding( - top = MaterialTheme.dimensions.keyline_12, - bottom = MaterialTheme.dimensions.keyline_4, - ), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - text = department.department, - ) - } - + EmptyContent(text = content.castMissingText) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(TestTags.Credits.CAST_CREDITS_CONTENT), + contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { items( - items = department.uniqueCrewList, - key = { - (it.role as PersonRole.Crew).creditId + (it.role as PersonRole.Crew).department - }, + items = content.cast, + key = { it.id }, ) { person -> PersonItem( person = person, @@ -123,6 +92,44 @@ fun CreditsContent( } } } + is CreditsUiContent.Crew -> { + if (content.crew.isEmpty()) { + EmptyContent(text = content.crewMissingText) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(TestTags.Credits.CREW_CREDITS_CONTENT), + contentPadding = PaddingValues(MaterialTheme.dimensions.keyline_12), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.dimensions.keyline_4), + ) { + content.crew.forEach { department -> + item { + Text( + modifier = Modifier.padding( + top = MaterialTheme.dimensions.keyline_12, + bottom = MaterialTheme.dimensions.keyline_4, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + text = department.department, + ) + } + items( + items = department.uniqueCrewList, + key = { + (it.role as PersonRole.Crew).creditId + (it.role as PersonRole.Crew).department + }, + ) { person -> + PersonItem( + person = person, + onClick = onPersonSelected, + ) + } + } + } + } + } } } } @@ -132,55 +139,58 @@ fun CreditsContent( @Composable private fun CreditsContentPreview() { AppTheme { - CreditsContent( - state = CreditsUiState( - selectedTabIndex = 0, - tabs = listOf( - CreditsTab.Cast(2), - CreditsTab.Crew(1), - ), - forms = mapOf( - CreditsTab.Cast(2) to CreditsUiContent.Cast( - cast = listOf( - Person( - id = 1, - name = "Person 1", - profilePath = "https://image.tmdb.org/t/p/w185/1.jpg", - role = PersonRole.SeriesActor( - character = "Character 1", + Surface { + CreditsContent( + state = CreditsUiState( + selectedTabIndex = 0, + tabs = listOf( + CreditsTab.Cast(2), + CreditsTab.Crew(1), + ), + forms = mapOf( + CreditsTab.Cast(2) to CreditsUiContent.Cast( + cast = listOf( + Person( + id = 1, + name = "Person 1", + profilePath = "https://image.tmdb.org/t/p/w185/1.jpg", + role = PersonRole.SeriesActor( + character = "Character 1", + ), ), - ), - Person( - id = 2, - name = "Person 2", - profilePath = "https://image.tmdb.org/t/p/w185/2.jpg", - role = PersonRole.SeriesActor( - character = "Character 2", + Person( + id = 2, + name = "Person 2", + profilePath = "https://image.tmdb.org/t/p/w185/2.jpg", + role = PersonRole.SeriesActor( + character = "Character 2", + totalEpisodes = 10, + ), ), ), ), - ), - CreditsTab.Crew(1) to CreditsUiContent.Crew( - crew = listOf( - SeriesCrewDepartment( - department = "Department 1", - crewList = listOf( - Person( - id = 3, - name = "Person 3", - profilePath = "https://image.tmdb.org/t/p/w185/3.jpg", - role = PersonRole.Crew( - job = "Job 3", - creditId = "Credit 3", + CreditsTab.Crew(1) to CreditsUiContent.Crew( + crew = listOf( + SeriesCrewDepartment( + department = "Department 1", + crewList = listOf( + Person( + id = 3, + name = "Person 3", + profilePath = "https://image.tmdb.org/t/p/w185/3.jpg", + role = PersonRole.Crew( + job = "Job 3", + creditId = "Credit 3", + ), ), - ), - Person( - id = 4, - name = "Person 4", - profilePath = "https://image.tmdb.org/t/p/w185/4.jpg", - role = PersonRole.Crew( - job = "Job 4", - creditId = "Credit 4", + Person( + id = 4, + name = "Person 4", + profilePath = "https://image.tmdb.org/t/p/w185/4.jpg", + role = PersonRole.Crew( + job = "Job 4", + creditId = "Credit 4", + ), ), ), ), @@ -188,9 +198,9 @@ private fun CreditsContentPreview() { ), ), ), - ), - onTabSelected = {}, - onPersonSelected = { }, - ) + onTabSelected = { }, + onPersonSelected = { }, + ) + } } } diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt index ecdb6a89..a9a675b6 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsScreen.kt @@ -59,7 +59,6 @@ fun CreditsScreen( ) } }, - ) }, ) { paddingValues -> diff --git a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt index a3db49ef..1c5a0e39 100644 --- a/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt +++ b/feature/credits/src/main/kotlin/com/divinelink/feature/credits/ui/CreditsUiContent.kt @@ -2,8 +2,15 @@ package com.divinelink.feature.credits.ui import com.divinelink.core.model.credits.SeriesCrewDepartment import com.divinelink.core.model.details.Person +import com.divinelink.core.ui.UIText +import com.divinelink.feature.credits.R sealed interface CreditsUiContent { - data class Cast(val cast: List) : CreditsUiContent - data class Crew(val crew: List) : CreditsUiContent + data class Cast(val cast: List) : CreditsUiContent { + val castMissingText: UIText = UIText.ResourceText(R.string.feature_credits_cast_missing) + } + + data class Crew(val crew: List) : CreditsUiContent { + val crewMissingText: UIText = UIText.ResourceText(R.string.feature_credits_crew_missing) + } } diff --git a/feature/credits/src/main/res/values/strings.xml b/feature/credits/src/main/res/values/strings.xml index f8c56faa..5cdf9ce4 100644 --- a/feature/credits/src/main/res/values/strings.xml +++ b/feature/credits/src/main/res/values/strings.xml @@ -4,4 +4,7 @@ Cast (%s) Crew (%s) (%s Episodes) + + No cast information available + No crew information available \ No newline at end of file diff --git a/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsScreenTest.kt b/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsScreenTest.kt new file mode 100644 index 00000000..ab0f5682 --- /dev/null +++ b/feature/credits/src/test/kotlin/com/divinelink/feature/credits/ui/CreditsScreenTest.kt @@ -0,0 +1,198 @@ +package com.divinelink.feature.credits.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.lifecycle.SavedStateHandle +import com.divinelink.core.model.media.MediaType +import com.divinelink.core.testing.ComposeTest +import com.divinelink.core.testing.factories.details.credits.AggregatedCreditsFactory +import com.divinelink.core.testing.getString +import com.divinelink.core.testing.navigator.FakeDestinationsNavigator +import com.divinelink.core.testing.setContentWithTheme +import com.divinelink.core.testing.usecase.TestFetchCreditsUseCase +import com.divinelink.core.ui.TestTags +import com.divinelink.feature.credits.R +import kotlinx.coroutines.flow.flowOf +import kotlin.test.BeforeTest +import kotlin.test.Test +import com.divinelink.core.ui.R as uiR + +class CreditsScreenTest : ComposeTest() { + + private lateinit var navigator: FakeDestinationsNavigator + private lateinit var fetchCreditsUseCase: TestFetchCreditsUseCase + private lateinit var savedStateHandle: SavedStateHandle + + @BeforeTest + fun setUp() { + navigator = FakeDestinationsNavigator() + fetchCreditsUseCase = TestFetchCreditsUseCase() + savedStateHandle = SavedStateHandle( + mapOf( + "id" to 2316L, + "mediaType" to MediaType.TV, + ), + ) + } + + @Test + fun `test topAppBar is visible`() { + val viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = savedStateHandle, + ) + + setContentWithTheme { + CreditsScreen( + navigator = navigator, + viewModel = viewModel, + ) + } + with(composeTestRule) { + onNodeWithText(getString(R.string.feature_credits_cast_and_crew_title)).assertIsDisplayed() + } + } + + @Test + fun `test back button is visible`() { + val viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = savedStateHandle, + ) + + setContentWithTheme { + CreditsScreen( + navigator = navigator, + viewModel = viewModel, + ) + } + with(composeTestRule) { + onNodeWithContentDescription( + getString(uiR.string.core_ui_navigate_up_button_content_description), + ).assertIsDisplayed() + } + } + + @Test + fun `test cast content is visible`() { + fetchCreditsUseCase.mockSuccess( + flowOf(Result.success(AggregatedCreditsFactory.credits())), + ) + val viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = savedStateHandle, + ) + + setContentWithTheme { + CreditsScreen( + navigator = navigator, + viewModel = viewModel, + ) + } + with(composeTestRule) { + onNodeWithTag(TestTags.Credits.CAST_CREDITS_CONTENT).assertIsDisplayed() + onNodeWithText( + AggregatedCreditsFactory.credits().cast.first().name, + ).assertIsDisplayed() + + onNodeWithTag(TestTags.Credits.CAST_CREDITS_CONTENT).performScrollToNode( + matcher = hasText(AggregatedCreditsFactory.credits().cast.last().name), + ) + + onNodeWithText( + AggregatedCreditsFactory.credits().cast.last().name, + ).assertIsDisplayed() + } + } + + @Test + fun `test crew content is visible`() { + fetchCreditsUseCase.mockSuccess( + flowOf(Result.success(AggregatedCreditsFactory.credits())), + ) + val viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = savedStateHandle, + ) + + setContentWithTheme { + CreditsScreen( + navigator = navigator, + viewModel = viewModel, + ) + } + with(composeTestRule) { + // Scroll pager to crew tab + // Tab has a text of Crew + number of unique crew members + onNodeWithText("Crew (12)").performClick() + + onNodeWithTag(TestTags.Credits.CREW_CREDITS_CONTENT).assertIsDisplayed() + onNodeWithText( + AggregatedCreditsFactory.credits().crewDepartments.first().department, + ).assertIsDisplayed() + + onNodeWithTag(TestTags.Credits.CREW_CREDITS_CONTENT).performScrollToNode( + matcher = hasText(AggregatedCreditsFactory.credits().crewDepartments.last().department), + ) + + onNodeWithText( + AggregatedCreditsFactory.credits().crewDepartments.last().department, + ).assertIsDisplayed() + } + } + + @Test + fun `test cast empty content`() { + fetchCreditsUseCase.mockSuccess( + flowOf(Result.success(AggregatedCreditsFactory.credits().copy(cast = emptyList()))), + ) + val viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = savedStateHandle, + ) + + setContentWithTheme { + CreditsScreen( + navigator = navigator, + viewModel = viewModel, + ) + } + with(composeTestRule) { + onNodeWithTag(TestTags.BLANK_SLATE).assertIsDisplayed() + onNodeWithText(getString(R.string.feature_credits_cast_missing)).assertIsDisplayed() + } + } + + @Test + fun `test crew empty content`() { + fetchCreditsUseCase.mockSuccess( + flowOf( + Result.success(AggregatedCreditsFactory.credits().copy(crewDepartments = emptyList())), + ), + ) + val viewModel = CreditsViewModel( + fetchCreditsUseCase = fetchCreditsUseCase.mock, + savedStateHandle = savedStateHandle, + ) + + setContentWithTheme { + CreditsScreen( + navigator = navigator, + viewModel = viewModel, + ) + } + with(composeTestRule) { + // Scroll pager to crew tab + // Tab has a text of Crew + number of unique crew members + onNodeWithText("Crew (0)").performClick() + + onNodeWithTag(TestTags.BLANK_SLATE).assertIsDisplayed() + onNodeWithText(getString(R.string.feature_credits_crew_missing)).assertIsDisplayed() + } + } +} diff --git a/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt b/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt index 1981b81b..a974aa1d 100644 --- a/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt +++ b/feature/watchlist/src/main/kotlin/com/divinelink/feature/watchlist/WatchlistScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel +import com.divinelink.core.ui.EmptyContent import com.divinelink.core.ui.TestTags import com.divinelink.core.ui.components.LoadingContent import com.divinelink.core.ui.components.scaffold.AppScaffold @@ -101,7 +102,7 @@ internal fun WatchlistScreen( ) is WatchlistForm.Data -> { if (it.isEmpty) { - WatchlistEmptyContent(it.emptyResultsUiText) + EmptyContent(it.emptyResultsUiText) } else { WatchlistContent( list = it.data,