diff --git a/app/src/main/java/com/strv/movies/data/OfflineMoviesProvider.kt b/app/src/main/java/com/strv/movies/data/OfflineMoviesProvider.kt deleted file mode 100644 index 45d2b0f..0000000 --- a/app/src/main/java/com/strv/movies/data/OfflineMoviesProvider.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.strv.movies.data - -import com.strv.movies.model.* - -object OfflineMoviesProvider { - fun getTrailer(movieId: Int): Trailer { - return Trailer( - "en", - "US", - "Official Trailer", - "JfVOs4VSpmA", - "YouTube", - 1080, - "Trailer", - true, - "2021-11-17T01:30:05.000Z", - "61945b8a4da3d4002992d5a6" - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/strv/movies/data/mapper/TrailerMapper.kt b/app/src/main/java/com/strv/movies/data/mapper/TrailerMapper.kt new file mode 100644 index 0000000..da7eb52 --- /dev/null +++ b/app/src/main/java/com/strv/movies/data/mapper/TrailerMapper.kt @@ -0,0 +1,17 @@ +package com.strv.movies.data.mapper + +import com.strv.movies.model.Trailer +import com.strv.movies.model.TrailerDTO +import javax.inject.Inject + +// Convention is to name a mapper after class of target object. +class TrailerMapper @Inject constructor() : Mapper { + override fun map(from: TrailerDTO) = + Trailer( + id = from.id, + name = from.name, + key = from.key, + publishedAt = from.publishedAt, + site = from.site + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/strv/movies/model/Trailer.kt b/app/src/main/java/com/strv/movies/model/Trailer.kt index fc8cfb5..2a61d77 100644 --- a/app/src/main/java/com/strv/movies/model/Trailer.kt +++ b/app/src/main/java/com/strv/movies/model/Trailer.kt @@ -1,14 +1,28 @@ package com.strv.movies.model +import com.squareup.moshi.Json + +data class TrailersDTO( + val id: Int?, + val results: List? +) + +// You can actually not use annotations in DTOs if your naming matches the server name. +// Here it is only used in case of publishedAt, because it is not camelcased in the response we get. +// I would generally recommend using it everywhere, simple name refactor could break things otherwise. +data class TrailerDTO( + val name: String?, + val key: String?, + val site: String?, + @Json(name = "published_at") + val publishedAt: String?, + val id: String? +) + data class Trailer( - val iso_639_1: String, - val iso_3166_1: String, - val name: String, - val key: String, - val site: String, - val size: Int, - val type: String, - val official: Boolean, - val publishedAt: String, - val id: String + val name: String?, + val key: String?, + val site: String?, + val publishedAt: String?, + val id: String? ) \ No newline at end of file diff --git a/app/src/main/java/com/strv/movies/network/MovieApi.kt b/app/src/main/java/com/strv/movies/network/MovieApi.kt index cbd44bc..8e7faca 100644 --- a/app/src/main/java/com/strv/movies/network/MovieApi.kt +++ b/app/src/main/java/com/strv/movies/network/MovieApi.kt @@ -2,6 +2,7 @@ package com.strv.movies.network import com.strv.movies.model.MovieDetailDTO import com.strv.movies.model.PopularMoviesDTO +import com.strv.movies.model.TrailersDTO import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query @@ -12,4 +13,7 @@ interface MovieApi { @GET("movie/popular") suspend fun getPopularMovies(@Query("page") page: Int = 1): PopularMoviesDTO + + @GET("movie/{movieId}/videos") + suspend fun getVideos(@Path("movieId") movieId: Int): TrailersDTO } \ No newline at end of file diff --git a/app/src/main/java/com/strv/movies/network/MovieRepository.kt b/app/src/main/java/com/strv/movies/network/MovieRepository.kt index 59dc7a3..79ef5d9 100644 --- a/app/src/main/java/com/strv/movies/network/MovieRepository.kt +++ b/app/src/main/java/com/strv/movies/network/MovieRepository.kt @@ -2,11 +2,11 @@ package com.strv.movies.network import com.strv.movies.data.mapper.MovieDetailMapper import com.strv.movies.data.mapper.MovieMapper +import com.strv.movies.data.mapper.TrailerMapper import com.strv.movies.extension.Either import com.strv.movies.model.Movie import com.strv.movies.model.MovieDetail -import com.strv.movies.model.MovieDetailDTO -import com.strv.movies.model.PopularMoviesDTO +import com.strv.movies.model.Trailer import javax.inject.Inject import javax.inject.Singleton @@ -14,14 +14,25 @@ import javax.inject.Singleton class MovieRepository @Inject constructor( private val api: MovieApi, private val movieDetailMapper: MovieDetailMapper, - private val movieMapper: MovieMapper + private val movieMapper: MovieMapper, + private val trailerMapper: TrailerMapper ) { suspend fun getMovieDetail(movieId: Int): Either { return try { val movie = api.getMovieDetail(movieId) Either.Value(movieDetailMapper.map(movie)) } catch (exception: Throwable) { - Either.Error(exception.localizedMessage?: "Network error") + Either.Error(exception.localizedMessage ?: "Network error") + } + } + + suspend fun getTrailers(movieId: Int): Either> { + return try { + val videos = api.getVideos(movieId) + Either.Value(videos.results?.map { trailerMapper.map(it) } + ?: emptyList()) // solving nullability from API nicely + } catch (exception: Throwable) { + Either.Error(exception.localizedMessage ?: "Network error") } } @@ -30,7 +41,7 @@ class MovieRepository @Inject constructor( val popularMovies = api.getPopularMovies() Either.Value(popularMovies.results.map { movieMapper.map(it) }) } catch (exception: Throwable) { - Either.Error(exception.localizedMessage?: "Network error") + Either.Error(exception.localizedMessage ?: "Network error") } } } \ No newline at end of file diff --git a/app/src/main/java/com/strv/movies/network/NetworkModule.kt b/app/src/main/java/com/strv/movies/network/NetworkModule.kt index 8c729b1..1dcf14f 100644 --- a/app/src/main/java/com/strv/movies/network/NetworkModule.kt +++ b/app/src/main/java/com/strv/movies/network/NetworkModule.kt @@ -1,5 +1,6 @@ package com.strv.movies.network +import android.util.Log import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.strv.movies.BuildConfig @@ -59,6 +60,7 @@ object NetworkModule { @Provides @Singleton fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor { message -> + Log.d("HttpLoggingInterceptor", message) }.apply { level = when (BuildConfig.BUILD_TYPE == "debug") { true -> HttpLoggingInterceptor.Level.BODY diff --git a/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailScreen.kt b/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailScreen.kt index ca2de99..502d7fc 100644 --- a/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailScreen.kt +++ b/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailScreen.kt @@ -27,8 +27,8 @@ import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView import com.strv.movies.R -import com.strv.movies.data.OfflineMoviesProvider import com.strv.movies.model.MovieDetail +import com.strv.movies.model.Trailer import com.strv.movies.ui.error.ErrorScreen import com.strv.movies.ui.loading.LoadingScreen @@ -46,6 +46,7 @@ fun MovieDetailScreen( viewState.movie?.let { MovieDetail( movie = it, + trailers = viewState.trailers, videoProgress = viewState.videoProgress, setVideoProgress = viewModel::updateVideoProgress ) @@ -56,17 +57,20 @@ fun MovieDetailScreen( @Composable fun MovieDetail( movie: MovieDetail, + trailers: Trailer?, videoProgress: Float = 0f, setVideoProgress: (second: Float) -> Unit ) { Column { Log.d("TAG", "MovieDetail: $videoProgress") - MovieTrailerPlayer( - videoId = OfflineMoviesProvider.getTrailer(movie.id).key, - progressSeconds = videoProgress, - setProgress = setVideoProgress - ) + if(trailers?.key != null) { + MovieTrailerPlayer( + videoId = trailers.key, + progressSeconds = videoProgress, + setProgress = setVideoProgress + ) + } Row { MoviePoster(movie = movie) diff --git a/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewModel.kt b/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewModel.kt index 9250b43..d321226 100644 --- a/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewModel.kt +++ b/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewModel.kt @@ -4,10 +4,12 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.strv.movies.extension.Either import com.strv.movies.extension.fold import com.strv.movies.network.MovieRepository import com.strv.movies.ui.navigation.MoviesNavArguments import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -30,24 +32,49 @@ class MovieDetailViewModel @Inject constructor( init { viewModelScope.launch { - movieRepository.getMovieDetail(movieId).fold( - { error -> + val movieDetail = async { movieRepository.getMovieDetail(movieId) } + val trailers = async { movieRepository.getTrailers(movieId) } + + val movieDetailResponse = movieDetail.await() + val trailersResponse = trailers.await() + + // We want to show error if we do not get movie detail. If trailer is not there, we can just hide it. + when (movieDetailResponse) { + is Either.Error -> { + // Here we use smart cast, so no need to .fold() here. We already checked the type in this WHEN branch. + val error = movieDetailResponse.error Log.d("TAG", "MovieDetailLoadingError: $error") _viewState.update { MovieDetailViewState( - error = error - ) - } - }, - { movie -> - Log.d("TAG", "MovieDetailSuccess: ${movie.title}") - _viewState.update { - MovieDetailViewState( - movie = movie + error = error // MovieDetail is crucial for this screen, set error. ) } } - ) + is Either.Value -> { + trailersResponse.fold( + { error -> + Log.d("TAG", "MovieTrailerLoadingError: $error") + _viewState.update { + MovieDetailViewState( + movie = movieDetailResponse.value // We do not care about this error too much + ) + } + }, + { trailerList -> + Log.d("TAG", "MovieTrailerSuccess: ${trailerList.size}") + _viewState.update { + MovieDetailViewState( + movie = movieDetailResponse.value, + // TODO This could be done better. + // Maybe when first trailer is not working for us, try to pick some other. + // We need to check if trailer is YouTube video. We can also e.g. pick only official trailers. + trailers = trailerList.first() + ) + } + } + ) + } + } } } diff --git a/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewState.kt b/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewState.kt index 0ebf59e..78e45cc 100644 --- a/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewState.kt +++ b/app/src/main/java/com/strv/movies/ui/moviedetail/MovieDetailViewState.kt @@ -1,9 +1,11 @@ package com.strv.movies.ui.moviedetail import com.strv.movies.model.MovieDetail +import com.strv.movies.model.Trailer data class MovieDetailViewState( val movie: MovieDetail? = null, + val trailers: Trailer? = null, val loading: Boolean = false, val error: String? = null, val videoProgress: Float = 0f