From 7866462935de8f6a6e440d3b106a331c968e07f9 Mon Sep 17 00:00:00 2001 From: Slee Date: Sat, 10 Feb 2024 09:51:31 -0800 Subject: [PATCH] Add feature images to detail screen --- app/build.gradle | 1 + .../main/java/com/sample/tmdb/ui/TMDbApp.kt | 33 ++++ .../java/com/sample/tmdb/ui/TMDbAppState.kt | 5 + buildSrc/src/main/java/Dependencies.kt | 1 + .../sample/tmdb/common/MainDestinations.kt | 3 + .../com/sample/tmdb/common/utils/Constants.kt | 1 + common/src/main/res/values/strings.xml | 1 + .../sample/tmdb/data/network/MovieService.kt | 4 + .../sample/tmdb/data/network/TVShowService.kt | 8 +- .../data/repository/MovieDetailRepository.kt | 6 +- .../data/repository/TVShowDetailRepository.kt | 8 +- .../com/sample/tmdb/data/response/ImageDto.kt | 30 +++ .../sample/tmdb/domain/model/DetailWrapper.kt | 3 +- .../com/sample/tmdb/domain/model/TMDbImage.kt | 3 + .../domain/repository/BaseDetailRepository.kt | 8 +- .../com/sample/tmdb/detail/DetailScreen.kt | 179 ++++++++++++++---- .../src/main/res/values/strings.xml | 1 + features/feature-image/.gitignore | 1 + features/feature-image/build.gradle.kts | 48 +++++ features/feature-image/consumer-rules.pro | 0 features/feature-image/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 4 + .../java/com/sample/tmdb/image/ImageScreen.kt | 133 +++++++++++++ .../src/main/res/values/strings.xml | 4 + settings.gradle | 1 + 25 files changed, 464 insertions(+), 43 deletions(-) create mode 100644 core/data/src/main/java/com/sample/tmdb/data/response/ImageDto.kt create mode 100644 core/domain/src/main/java/com/sample/tmdb/domain/model/TMDbImage.kt create mode 100644 features/feature-image/.gitignore create mode 100644 features/feature-image/build.gradle.kts create mode 100644 features/feature-image/consumer-rules.pro create mode 100644 features/feature-image/proguard-rules.pro create mode 100644 features/feature-image/src/main/AndroidManifest.xml create mode 100644 features/feature-image/src/main/java/com/sample/tmdb/image/ImageScreen.kt create mode 100644 features/feature-image/src/main/res/values/strings.xml diff --git a/app/build.gradle b/app/build.gradle index 6818f31..b57c741 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -71,6 +71,7 @@ dependencies { implementation project(path: BuildModules.FEATURE_PAGING) implementation project(path: BuildModules.FEATURE_DETAIL) implementation project(path: BuildModules.FEATURE_CREDIT) + implementation project(path: BuildModules.FEATURE_IMAGE) implementation Deps.material implementation Deps.composeMaterial diff --git a/app/src/main/java/com/sample/tmdb/ui/TMDbApp.kt b/app/src/main/java/com/sample/tmdb/ui/TMDbApp.kt index fe2abc1..e035167 100644 --- a/app/src/main/java/com/sample/tmdb/ui/TMDbApp.kt +++ b/app/src/main/java/com/sample/tmdb/ui/TMDbApp.kt @@ -44,8 +44,10 @@ import com.sample.tmdb.detail.MovieDetailScreen import com.sample.tmdb.detail.TVShowDetailScreen import com.sample.tmdb.domain.model.Cast import com.sample.tmdb.domain.model.Crew +import com.sample.tmdb.domain.model.TMDbImage import com.sample.tmdb.feed.MovieFeedScreen import com.sample.tmdb.feed.TVShowFeedScreen +import com.sample.tmdb.image.ImagesScreen import com.sample.tmdb.paging.AiringTodayTVShowScreen import com.sample.tmdb.paging.DiscoverMovieScreen import com.sample.tmdb.paging.DiscoverTVShowScreen @@ -92,6 +94,7 @@ fun TMDbApp() { onAllCastSelected = appState::navigateToCastList, onAllCrewSelected = appState::navigateToCrewList, onCreditSelected = appState::navigateToPerson, + onImagesSelected = appState::navigateToImages, upPress = appState::upPress ) moviePagingScreens( @@ -114,6 +117,7 @@ fun TMDbApp() { upPress = appState::upPress ) personScreen(upPress = appState::upPress) + imagesScreen() } } } @@ -192,6 +196,7 @@ private fun NavGraphBuilder.detailScreens( onAllCastSelected: (String) -> Unit, onAllCrewSelected: (String) -> Unit, onCreditSelected: (String) -> Unit, + onImagesSelected: (String, Int) -> Unit, upPress: () -> Unit ) { composable( @@ -209,6 +214,11 @@ private fun NavGraphBuilder.detailScreens( ) }, onCreditSelected = { onCreditSelected(it) + }, onImagesSelected = { images, index -> + onImagesSelected( + Uri.encode(gson.toJson(images, object : TypeToken>() {}.type)), + index + ) }) } composable( @@ -226,6 +236,11 @@ private fun NavGraphBuilder.detailScreens( ) }, onCreditSelected = { onCreditSelected(it) + }, onImagesSelected = { images, index -> + onImagesSelected( + Uri.encode(gson.toJson(images, object : TypeToken>() {}.type)), + index + ) }) } } @@ -393,6 +408,24 @@ private fun NavGraphBuilder.personScreen( } } +private fun NavGraphBuilder.imagesScreen() { + composable( + route = "${MainDestinations.TMDB_IMAGES_ROUTE}/{${MainDestinations.TMDB_IMAGES_KEY}}/{${MainDestinations.TMDB_IMAGE_ID}}", + arguments = listOf( + navArgument(MainDestinations.TMDB_IMAGES_KEY) { type = NavType.StringType }, + navArgument(MainDestinations.TMDB_IMAGE_ID) { type = NavType.IntType } + ) + ) { from -> + ImagesScreen( + images = gson.fromJson( + from.arguments?.getString(MainDestinations.TMDB_IMAGES_KEY), + object : TypeToken>() {}.type + ), + initialPage = from.arguments?.getInt(MainDestinations.TMDB_IMAGE_ID)!! + ) + } +} + enum class HomeSections( val route: String, @StringRes val title: Int, diff --git a/app/src/main/java/com/sample/tmdb/ui/TMDbAppState.kt b/app/src/main/java/com/sample/tmdb/ui/TMDbAppState.kt index a8394d9..c8ac9c5 100644 --- a/app/src/main/java/com/sample/tmdb/ui/TMDbAppState.kt +++ b/app/src/main/java/com/sample/tmdb/ui/TMDbAppState.kt @@ -92,6 +92,11 @@ class TMDbAppState( fun navigateToSearchTVShow() { navController.navigate(MainDestinations.TMDB_SEARCH_TV_SHOW_ROUTE) } + + fun navigateToImages(images: String, id: Int) { + navController.navigate("${MainDestinations.TMDB_IMAGES_ROUTE}/$images/$id") + } + } private val NavGraph.startDestination: NavDestination? diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index 141ff8b..a556e93 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -47,6 +47,7 @@ object BuildModules { const val FEATURE_PAGING = ":features:feature-paging" const val FEATURE_DETAIL = ":features:feature-detail" const val FEATURE_CREDIT = ":features:feature-credit" + const val FEATURE_IMAGE = ":features:feature-image" } object Deps { diff --git a/common/src/main/java/com/sample/tmdb/common/MainDestinations.kt b/common/src/main/java/com/sample/tmdb/common/MainDestinations.kt index df2f8a0..24f6e29 100644 --- a/common/src/main/java/com/sample/tmdb/common/MainDestinations.kt +++ b/common/src/main/java/com/sample/tmdb/common/MainDestinations.kt @@ -24,4 +24,7 @@ object MainDestinations { const val TMDB_CREDIT_KEY = "credit_list" const val TMDB_PERSON_ROUTE = "person" const val TMDB_PERSON_KEY = "personId" + const val TMDB_IMAGES_ROUTE = "image" + const val TMDB_IMAGES_KEY = "imagesKey" + const val TMDB_IMAGE_ID = "imageId" } \ No newline at end of file diff --git a/common/src/main/java/com/sample/tmdb/common/utils/Constants.kt b/common/src/main/java/com/sample/tmdb/common/utils/Constants.kt index 4d28524..912ee39 100644 --- a/common/src/main/java/com/sample/tmdb/common/utils/Constants.kt +++ b/common/src/main/java/com/sample/tmdb/common/utils/Constants.kt @@ -3,6 +3,7 @@ package com.sample.tmdb.common.utils object Constants { const val BASE_WIDTH_342_PATH = "http://image.tmdb.org/t/p/w342%s" const val BASE_WIDTH_780_PATH = "http://image.tmdb.org/t/p/w780%s" + const val BASE_IMAGE_PATH = "https://image.tmdb.org/t/p/original%s" const val ID = "id" const val NAME = "name" const val TITLE = "title" diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 504535c..327e4b5 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -9,4 +9,5 @@ Back Photo of %1$s the %2$s + Poster \ No newline at end of file diff --git a/core/data/src/main/java/com/sample/tmdb/data/network/MovieService.kt b/core/data/src/main/java/com/sample/tmdb/data/network/MovieService.kt index 588a9fc..dd49235 100644 --- a/core/data/src/main/java/com/sample/tmdb/data/network/MovieService.kt +++ b/core/data/src/main/java/com/sample/tmdb/data/network/MovieService.kt @@ -1,5 +1,6 @@ package com.sample.tmdb.data.network +import com.sample.tmdb.data.response.ImagesResponse import com.sample.tmdb.data.response.MovieDetailResponse import com.sample.tmdb.data.response.NetworkCreditWrapper import com.sample.tmdb.data.response.NetworkMovie @@ -57,4 +58,7 @@ interface MovieService { @Query("page") page: Int, @Query("query") query: String ): TMDbWrapper + + @GET("3/movie/{movie_id}/images") + suspend fun fetchImages(@Path("movie_id") movieId: Int): ImagesResponse } \ No newline at end of file diff --git a/core/data/src/main/java/com/sample/tmdb/data/network/TVShowService.kt b/core/data/src/main/java/com/sample/tmdb/data/network/TVShowService.kt index 8f545b5..71413ee 100644 --- a/core/data/src/main/java/com/sample/tmdb/data/network/TVShowService.kt +++ b/core/data/src/main/java/com/sample/tmdb/data/network/TVShowService.kt @@ -1,5 +1,6 @@ package com.sample.tmdb.data.network +import com.sample.tmdb.data.response.ImagesResponse import com.sample.tmdb.data.response.NetworkCreditWrapper import com.sample.tmdb.data.response.NetworkTVShow import com.sample.tmdb.data.response.TMDbWrapper @@ -49,12 +50,15 @@ interface TVShowService { @GET("3/tv/{tvId}/credits") suspend fun tvCredit(@Path("tvId") tvId: Int): NetworkCreditWrapper - @GET("3/tv/{tv_id}") - suspend fun fetchTvDetail(@Path("tv_id") tvId: Int): TvDetailResponse + @GET("3/tv/{tvId}") + suspend fun fetchTvDetail(@Path("tvId") tvId: Int): TvDetailResponse @GET("3/search/tv") suspend fun searchTVSeries( @Query("page") page: Int, @Query("query") query: String ): TMDbWrapper + + @GET("3/tv/{tvId}/images") + suspend fun fetchImages(@Path("tvId") tvId: Int): ImagesResponse } \ No newline at end of file diff --git a/core/data/src/main/java/com/sample/tmdb/data/repository/MovieDetailRepository.kt b/core/data/src/main/java/com/sample/tmdb/data/repository/MovieDetailRepository.kt index c8d023f..ce865b3 100644 --- a/core/data/src/main/java/com/sample/tmdb/data/repository/MovieDetailRepository.kt +++ b/core/data/src/main/java/com/sample/tmdb/data/repository/MovieDetailRepository.kt @@ -1,14 +1,15 @@ package com.sample.tmdb.data.repository import android.content.Context +import com.sample.tmdb.data.di.IoDispatcher import com.sample.tmdb.data.network.MovieService import com.sample.tmdb.data.response.asCastDomainModel import com.sample.tmdb.data.response.asCrewDomainModel import com.sample.tmdb.data.response.asDomainModel -import com.sample.tmdb.data.di.IoDispatcher import com.sample.tmdb.domain.model.Cast import com.sample.tmdb.domain.model.Crew import com.sample.tmdb.domain.model.MovieDetails +import com.sample.tmdb.domain.model.TMDbImage import com.sample.tmdb.domain.repository.BaseDetailRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -32,4 +33,7 @@ class MovieDetailRepository @Inject constructor( networkCreditWrapper.crew.asCrewDomainModel() ) } + + override suspend fun getImages(id: Int): List = + movieApi.fetchImages(id).asDomainModel() } \ No newline at end of file diff --git a/core/data/src/main/java/com/sample/tmdb/data/repository/TVShowDetailRepository.kt b/core/data/src/main/java/com/sample/tmdb/data/repository/TVShowDetailRepository.kt index 38c6290..4f531fd 100644 --- a/core/data/src/main/java/com/sample/tmdb/data/repository/TVShowDetailRepository.kt +++ b/core/data/src/main/java/com/sample/tmdb/data/repository/TVShowDetailRepository.kt @@ -1,15 +1,16 @@ package com.sample.tmdb.data.repository import android.content.Context +import com.sample.tmdb.data.di.IoDispatcher import com.sample.tmdb.data.network.TVShowService import com.sample.tmdb.data.response.asCastDomainModel import com.sample.tmdb.data.response.asCrewDomainModel import com.sample.tmdb.data.response.asDomainModel -import com.sample.tmdb.data.di.IoDispatcher import com.sample.tmdb.domain.model.Cast import com.sample.tmdb.domain.model.Crew -import com.sample.tmdb.domain.repository.BaseDetailRepository +import com.sample.tmdb.domain.model.TMDbImage import com.sample.tmdb.domain.model.TvDetails +import com.sample.tmdb.domain.repository.BaseDetailRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject @@ -32,4 +33,7 @@ class TVShowDetailRepository @Inject constructor( networkCreditWrapper.crew.asCrewDomainModel() ) } + + override suspend fun getImages(id: Int): List = + tvShowApi.fetchImages(id).asDomainModel() } \ No newline at end of file diff --git a/core/data/src/main/java/com/sample/tmdb/data/response/ImageDto.kt b/core/data/src/main/java/com/sample/tmdb/data/response/ImageDto.kt new file mode 100644 index 0000000..e6ede7d --- /dev/null +++ b/core/data/src/main/java/com/sample/tmdb/data/response/ImageDto.kt @@ -0,0 +1,30 @@ +package com.sample.tmdb.data.response + +import com.sample.tmdb.common.utils.Constants.BASE_IMAGE_PATH +import com.sample.tmdb.domain.model.TMDbImage +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ImagesResponse( + @Json(name = "backdrops") val backdrops: List, + @Json(name = "id") val id: Int, + @Json(name = "posters") val posters: List, +) + +@JsonClass(generateAdapter = true) +data class ImageResponse( + @Json(name = "aspect_ratio") val aspectRatio: Double, + @Json(name = "file_path") val filePath: String, + @Json(name = "height") val height: Int, + @Json(name = "iso_639_1") val iso6391: String?, + @Json(name = "vote_average") val voteAverage: Double, + @Json(name = "vote_count") val voteCount: Int, + @Json(name = "width") val width: Int, +) + +fun ImagesResponse.asDomainModel(): List = buildList { + addAll(backdrops.map { TMDbImage(String.format(BASE_IMAGE_PATH, it.filePath), it.voteCount) }) + addAll(posters.map { TMDbImage(String.format(BASE_IMAGE_PATH, it.filePath), it.voteCount) }) + sortByDescending { it.voteCount } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/sample/tmdb/domain/model/DetailWrapper.kt b/core/domain/src/main/java/com/sample/tmdb/domain/model/DetailWrapper.kt index b33a63f..4f3ff47 100644 --- a/core/domain/src/main/java/com/sample/tmdb/domain/model/DetailWrapper.kt +++ b/core/domain/src/main/java/com/sample/tmdb/domain/model/DetailWrapper.kt @@ -3,5 +3,6 @@ package com.sample.tmdb.domain.model class DetailWrapper( val cast: List, val crew: List, - val details: T + val details: T, + val images: List ) \ No newline at end of file diff --git a/core/domain/src/main/java/com/sample/tmdb/domain/model/TMDbImage.kt b/core/domain/src/main/java/com/sample/tmdb/domain/model/TMDbImage.kt new file mode 100644 index 0000000..f40e3f0 --- /dev/null +++ b/core/domain/src/main/java/com/sample/tmdb/domain/model/TMDbImage.kt @@ -0,0 +1,3 @@ +package com.sample.tmdb.domain.model + +data class TMDbImage(val url: String, val voteCount: Int) \ No newline at end of file diff --git a/core/domain/src/main/java/com/sample/tmdb/domain/repository/BaseDetailRepository.kt b/core/domain/src/main/java/com/sample/tmdb/domain/repository/BaseDetailRepository.kt index a9e13e3..f10e769 100644 --- a/core/domain/src/main/java/com/sample/tmdb/domain/repository/BaseDetailRepository.kt +++ b/core/domain/src/main/java/com/sample/tmdb/domain/repository/BaseDetailRepository.kt @@ -5,6 +5,7 @@ import com.sample.tmdb.common.base.BaseRepository import com.sample.tmdb.domain.model.Cast import com.sample.tmdb.domain.model.Crew import com.sample.tmdb.domain.model.DetailWrapper +import com.sample.tmdb.domain.model.TMDbImage import com.sample.tmdb.domain.model.TMDbItemDetails import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Deferred @@ -20,16 +21,21 @@ abstract class BaseDetailRepository( protected abstract suspend fun getCredit(id: Int): Pair, List> + protected abstract suspend fun getImages(id: Int): List + override suspend fun getSuccessResult(id: Any?): DetailWrapper { val detailsDeferred: Deferred val creditDeferred: Deferred, List>> + val imageDeferred: Deferred> coroutineScope { detailsDeferred = async { getDetails(id as Int) } creditDeferred = async { getCredit(id as Int) } + imageDeferred= async { getImages(id as Int) } } val details = detailsDeferred.await() val creditWrapper = creditDeferred.await() + val images = imageDeferred.await() - return DetailWrapper(creditWrapper.first, creditWrapper.second, details) + return DetailWrapper(creditWrapper.first, creditWrapper.second, details, images) } } \ No newline at end of file diff --git a/features/feature-detail/src/main/java/com/sample/tmdb/detail/DetailScreen.kt b/features/feature-detail/src/main/java/com/sample/tmdb/detail/DetailScreen.kt index 2db57a6..aaff68d 100644 --- a/features/feature-detail/src/main/java/com/sample/tmdb/detail/DetailScreen.kt +++ b/features/feature-detail/src/main/java/com/sample/tmdb/detail/DetailScreen.kt @@ -11,16 +11,58 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FabPosition +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarHalf +import androidx.compose.material.icons.filled.StarOutline import androidx.compose.material.icons.rounded.OpenInNew -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,10 +71,12 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -56,22 +100,25 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.palette.graphics.Palette import coil.ImageLoader import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import coil.request.SuccessResult -import com.sample.tmdb.common.model.Credit import com.sample.tmdb.common.model.TMDbItem import com.sample.tmdb.common.ui.Content import com.sample.tmdb.common.ui.Dimens import com.sample.tmdb.common.ui.component.PersonCard +import com.sample.tmdb.common.ui.theme.imageTint import com.sample.tmdb.common.utils.dpToPx import com.sample.tmdb.common.utils.toDp +import com.sample.tmdb.detail.utils.openInChromeCustomTab import com.sample.tmdb.domain.model.Cast import com.sample.tmdb.domain.model.Crew import com.sample.tmdb.domain.model.Genre import com.sample.tmdb.domain.model.Movie +import com.sample.tmdb.domain.model.TMDbImage import com.sample.tmdb.domain.model.TMDbItemDetails import com.sample.tmdb.domain.model.TVShow -import com.sample.tmdb.detail.utils.openInChromeCustomTab import com.sample.tmdb.common.R as R1 @Composable @@ -80,6 +127,7 @@ fun MovieDetailScreen( onAllCastSelected: (List) -> Unit, onAllCrewSelected: (List) -> Unit, onCreditSelected: (String) -> Unit, + onImagesSelected: (List, Int) -> Unit, viewModel: MovieDetailViewModel = hiltViewModel() ) { DetailScreen( @@ -88,6 +136,7 @@ fun MovieDetailScreen( onAllCastSelected = onAllCastSelected, onAllCrewSelected = onAllCrewSelected, onCreditSelected = onCreditSelected, + onImagesSelected = onImagesSelected ) { details -> Movie( id = details.id, @@ -108,6 +157,7 @@ fun TVShowDetailScreen( onAllCastSelected: (List) -> Unit, onAllCrewSelected: (List) -> Unit, onCreditSelected: (String) -> Unit, + onImagesSelected: (List, Int) -> Unit, viewModel: TVShowDetailViewModel = hiltViewModel() ) { DetailScreen( @@ -116,6 +166,7 @@ fun TVShowDetailScreen( onAllCastSelected = onAllCastSelected, onAllCrewSelected = onAllCrewSelected, onCreditSelected = onCreditSelected, + onImagesSelected = onImagesSelected ) { details -> TVShow( id = details.id, @@ -137,6 +188,7 @@ private fun DetailScreen( onAllCastSelected: (List) -> Unit, onAllCrewSelected: (List) -> Unit, onCreditSelected: (String) -> Unit, + onImagesSelected: (List, Int) -> Unit, getBookmarkedItem: (T) -> E ) { DetailScreen( @@ -145,6 +197,7 @@ private fun DetailScreen( onAllCastSelected = onAllCastSelected, onAllCrewSelected = onAllCrewSelected, onCreditSelected = onCreditSelected, + onImagesSelected = onImagesSelected, fab = { isFabVisible, isBookmark, details -> ToggleBookmarkFab(isBookmark = isBookmark, isVisible = isFabVisible) { if (isBookmark) { @@ -167,6 +220,7 @@ fun DetailScreen( onAllCastSelected: (List) -> Unit, onAllCrewSelected: (List) -> Unit, onCreditSelected: (String) -> Unit, + onImagesSelected: (List, Int) -> Unit, fab: @Composable (MutableState, Boolean, T) -> Unit ) { // Visibility for FAB @@ -213,7 +267,7 @@ fun DetailScreen( .padding(contentPadding) ) { val (appbar, backdrop, poster, title, originalTitle, genres, specs, rateStars, tagline, overview) = createRefs() - val (castSection, crewSection, space) = createRefs() + val (castSection, crewSection, imagesSection, space) = createRefs() val startGuideline = createGuidelineFromStart(16.dp) val endGuideline = createGuidelineFromEnd(16.dp) @@ -227,7 +281,9 @@ fun DetailScreen( Backdrop( backdropUrl = backdropPath, it.details.title, - Modifier.constrainAs(backdrop) {}) + Modifier.constrainAs(backdrop) { + top.linkTo(parent.top) + }) } val posterWidth = 160.dp AppBar( @@ -342,7 +398,7 @@ fun DetailScreen( linkTo(startGuideline, endGuideline) } ) - CreditSection( + TMDbDetailItemSection( items = it.cast, headerResId = R.string.cast, itemContent = { item, _ -> @@ -352,13 +408,14 @@ fun DetailScreen( Modifier.width(140.dp) ) }, - onAllCreditSelected = onAllCastSelected, + onSeeAllClicked = { cast -> onAllCastSelected.invoke(cast) }, modifier = Modifier.constrainAs(castSection) { top.linkTo(overview.bottom, 16.dp) linkTo(startGuideline, endGuideline) } ) - CreditSection( + + TMDbDetailItemSection( items = it.crew, headerResId = R.string.crew, itemContent = { item, _ -> @@ -368,18 +425,33 @@ fun DetailScreen( Modifier.width(140.dp) ) }, - onAllCreditSelected = onAllCrewSelected, + onSeeAllClicked = { crew -> onAllCrewSelected.invoke(crew) }, modifier = Modifier.constrainAs(crewSection) { top.linkTo(castSection.bottom, 16.dp) linkTo(startGuideline, endGuideline) } ) + TMDbDetailItemSection( + items = it.images, + headerResId = R.string.images, + onSeeAllClicked = { images -> onImagesSelected.invoke(images, 0) }, + itemContent = { item, index -> + ImageSection( + item, + ) { onImagesSelected.invoke(it.images, index) } + }, + modifier = Modifier.constrainAs(imagesSection) { + top.linkTo(crewSection.bottom, 16.dp) + linkTo(startGuideline, endGuideline) + }, + ) + Spacer( modifier = Modifier .windowInsetsBottomHeight(WindowInsets.navigationBars) .constrainAs(space) { - top.linkTo(crewSection.bottom) + top.linkTo(imagesSection.bottom) } ) } @@ -470,7 +542,7 @@ private fun Poster(posterUrl: String?, tmdbItemName: String, modifier: Modifier) val scale = animateFloatAsState( targetValue = if (isScaled.value) 2.2f else 1f, - animationSpec = springAnimation + animationSpec = springAnimation, label = "" ).value Card( @@ -572,37 +644,35 @@ private fun RateStars(voteAverage: Double, modifier: Modifier) { } @Composable -private fun CreditSection( +private fun TMDbDetailItemSection( items: List, @StringRes headerResId: Int, + onSeeAllClicked: (List) -> Unit, itemContent: @Composable (T, Int) -> Unit, - onAllCreditSelected: (List) -> Unit, - modifier: Modifier + modifier: Modifier, ) { - if (items.isNotEmpty()) { - Column(modifier = modifier.fillMaxWidth()) { - SectionHeader(headerResId, items, onAllCreditSelected) - LazyRow( - modifier = Modifier.testTag(LocalContext.current.getString(headerResId)), - contentPadding = PaddingValues(Dimens.PaddingLarge) - ) { - items( - count = items.size, - itemContent = { index -> - itemContent(items[index], index) - Spacer(modifier = Modifier.width(Dimens.PaddingLarge)) - } - ) - } + Column(modifier = modifier.fillMaxWidth()) { + SectionHeader(headerResId, items, onSeeAllClicked) + LazyRow( + modifier = Modifier.testTag(LocalContext.current.getString(headerResId)), + contentPadding = PaddingValues(Dimens.PaddingLarge), + ) { + items( + count = items.size, + itemContent = { index -> + itemContent(items[index], index) + Spacer(modifier = Modifier.width(16.dp)) + }, + ) } } } @Composable -private fun SectionHeader( +private fun SectionHeader( @StringRes headerResId: Int, items: List, - onAllCreditSelected: (List) -> Unit + onAllSelected: (List) -> Unit ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -621,7 +691,7 @@ private fun SectionHeader( modifier = Modifier .padding(Dimens.PaddingExtraSmall) .clickable { - onAllCreditSelected.invoke(items) + onAllSelected.invoke(items) } ) { Text( @@ -639,6 +709,43 @@ private fun SectionHeader( } } +@Composable +private fun ImageSection( + image: TMDbImage, + onImageSelected: () -> Unit, +) { + Card( + Modifier + .width(240.dp) + .height(160.dp) + .clickable { onImageSelected.invoke() }, + shape = RoundedCornerShape(12.dp), + elevation = 8.dp, + ) { + val request = ImageRequest.Builder(LocalContext.current) + .data(image.url) + .crossfade(true) + .build() + val painter = rememberAsyncImagePainter( + model = request, + placeholder = rememberVectorPainter(Icons.Default.Image), + error = rememberVectorPainter(Icons.Default.BrokenImage), + ) + val (colorFilter, contentScale) = when (painter.state) { + is AsyncImagePainter.State.Error, is AsyncImagePainter.State.Loading -> + ColorFilter.tint(MaterialTheme.colors.imageTint) to ContentScale.Fit + + else -> null to ContentScale.Crop + } + Image( + painter = painter, + colorFilter = colorFilter, + contentDescription = stringResource(id = R1.string.poster_content_description), + contentScale = contentScale, + ) + } +} + @Composable private fun ToggleBookmarkFab( isBookmark: Boolean, diff --git a/features/feature-detail/src/main/res/values/strings.xml b/features/feature-detail/src/main/res/values/strings.xml index 9e9ac67..0e0d02e 100644 --- a/features/feature-detail/src/main/res/values/strings.xml +++ b/features/feature-detail/src/main/res/values/strings.xml @@ -9,4 +9,5 @@ Open Favorite Un-favorite + Images \ No newline at end of file diff --git a/features/feature-image/.gitignore b/features/feature-image/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/features/feature-image/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/feature-image/build.gradle.kts b/features/feature-image/build.gradle.kts new file mode 100644 index 0000000..b3b352c --- /dev/null +++ b/features/feature-image/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.sample.tmdb.image" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.0" + } +} + +dependencies { + implementation(project(mapOf("path" to BuildModules.DOMAIN))) + + implementation(Deps.composeUi) + implementation(Deps.composeFoundation) + implementation(Deps.composeMaterial) + implementation(Deps.coil) +} \ No newline at end of file diff --git a/features/feature-image/consumer-rules.pro b/features/feature-image/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/features/feature-image/proguard-rules.pro b/features/feature-image/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/features/feature-image/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/features/feature-image/src/main/AndroidManifest.xml b/features/feature-image/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/features/feature-image/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/features/feature-image/src/main/java/com/sample/tmdb/image/ImageScreen.kt b/features/feature-image/src/main/java/com/sample/tmdb/image/ImageScreen.kt new file mode 100644 index 0000000..36ad35c --- /dev/null +++ b/features/feature-image/src/main/java/com/sample/tmdb/image/ImageScreen.kt @@ -0,0 +1,133 @@ +package com.sample.tmdb.image + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter +import com.sample.tmdb.domain.model.TMDbImage +import com.sample.tmdb.common.R as R1 + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ImagesScreen(images: List, initialPage: Int) { + if (images.isEmpty() || initialPage !in images.indices) return + + val pagerState = rememberPagerState( + initialPage = initialPage, + initialPageOffsetFraction = 0f + ) { images.size } + Box { + HorizontalPager(state = pagerState, key = { images[it].url + it }, beyondBoundsPageCount = 4) { + Poster(images[it]) + } + Index(position = pagerState.currentPage + 1, imageCount = pagerState.pageCount) + } +} + +@Composable +private fun Poster(image: TMDbImage) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { + BlurImage(image.url) + Card( + Modifier + .systemBarsPadding() + .padding(12.dp) + .shadow(16.dp, RoundedCornerShape(12.dp)) + .animateContentSize() + .wrapContentSize() + ) { + Box { + Image( + painter = rememberAsyncImagePainter(image.url), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .wrapContentHeight(), + contentScale = ContentScale.FillWidth, + ) + VoteCount(image.voteCount) + } + } + } +} + +@Composable +private fun BlurImage(url: String) { + AsyncImage( + model = url, + contentDescription = stringResource(id = R1.string.poster_content_description), + contentScale = ContentScale.FillHeight, + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colors.surface) + .blur(16.dp), + ) +} + +@Composable +private fun BoxScope.VoteCount(voteCount: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .wrapContentSize() + .align(Alignment.BottomStart) + .background( + color = MaterialTheme.colors.surface.copy(alpha = 0.3f), + shape = RoundedCornerShape(bottomStart = 12.dp, topEnd = 12.dp), + ) + .padding(4.dp), + ) { + Icon( + imageVector = Icons.Filled.Favorite, + tint = MaterialTheme.colors.primary, + contentDescription = stringResource(id = R.string.likes_content_description), + modifier = Modifier.padding(end = 4.dp), + ) + Text(text = voteCount.toString(), style = MaterialTheme.typography.body2) + } +} + +@Composable +private fun BoxScope.Index(position: Int, imageCount: Int) { + Text( + text = "$position / $imageCount", + style = MaterialTheme.typography.body2, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(4.dp) + .shadow(16.dp, RoundedCornerShape(16.dp)) + .background(color = MaterialTheme.colors.surface.copy(alpha = 0.3f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} \ No newline at end of file diff --git a/features/feature-image/src/main/res/values/strings.xml b/features/feature-image/src/main/res/values/strings.xml new file mode 100644 index 0000000..310d53b --- /dev/null +++ b/features/feature-image/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Likes + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 5eb7188..b72868b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -23,3 +23,4 @@ include ':features:feature-credit' include ':features:feature-setting' include ':features:feature-detail' include ':features:feature-paging' +include ':features:feature-image'