├── data
├── .gitignore
├── consumer-rules.pro
├── src
│ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── com
│ │ │ └── ang
│ │ │ └── acb
│ │ │ └── movienight
│ │ │ └── data
│ │ │ ├── source
│ │ │ ├── remote
│ │ │ │ ├── response
│ │ │ │ │ ├── NetworkGenre.kt
│ │ │ │ │ ├── NetworkVideo.kt
│ │ │ │ │ ├── NetworkCast.kt
│ │ │ │ │ ├── CreditsResponse.kt
│ │ │ │ │ ├── MoviesResponse.kt
│ │ │ │ │ ├── VideosResponse.kt
│ │ │ │ │ ├── NetworkCastDetails.kt
│ │ │ │ │ ├── NetworkMovie.kt
│ │ │ │ │ └── NetworkMovieDetails.kt
│ │ │ │ ├── RemoteMovieDataSource.kt
│ │ │ │ ├── MovieService.kt
│ │ │ │ └── RemoteMovieMappers.kt
│ │ │ └── local
│ │ │ │ ├── MovieDatabase.kt
│ │ │ │ ├── MovieMappers.kt
│ │ │ │ ├── FavoriteMovie.kt
│ │ │ │ ├── MovieDao.kt
│ │ │ │ └── LocalMovieDataSource.kt
│ │ │ └── MovieRepository.kt
│ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── ang
│ │ │ └── acb
│ │ │ └── movienight
│ │ │ └── data
│ │ │ ├── utils
│ │ │ └── MainCoroutineRule.kt
│ │ │ └── source
│ │ │ └── local
│ │ │ ├── MovieDaoTest.kt
│ │ │ └── LocalMovieDataSourceTest.kt
│ └── test
│ │ ├── resources
│ │ └── response
│ │ │ └── cast_details_alpacino.json
│ │ └── java
│ │ └── com
│ │ └── ang
│ │ └── acb
│ │ └── movienight
│ │ └── data
│ │ └── source
│ │ └── remote
│ │ └── MovieServiceTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── domain
├── .gitignore
├── src
│ └── main
│ │ └── java
│ │ └── com
│ │ └── ang
│ │ └── acb
│ │ └── movienight
│ │ └── domain
│ │ ├── entities
│ │ ├── Genre.kt
│ │ ├── MovieFilter.kt
│ │ ├── Movies.kt
│ │ ├── MovieDetails.kt
│ │ ├── Cast.kt
│ │ ├── CastDetails.kt
│ │ ├── Movie.kt
│ │ └── Trailer.kt
│ │ ├── usecases
│ │ ├── DeleteFavoriteMovieUseCase.kt
│ │ ├── GetPopularMoviesUseCase.kt
│ │ ├── SaveFavoriteMovieUseCase.kt
│ │ ├── GetTopRatedMoviesUseCase.kt
│ │ ├── GetUpcomingMoviesUseCase.kt
│ │ ├── GetCastDetailsUseCase.kt
│ │ ├── GetNowPlayingMoviesUseCase.kt
│ │ ├── SearchMoviesUseCase.kt
│ │ ├── GetMovieDetailsUseCase.kt
│ │ ├── GetSimilarMoviesUseCase.kt
│ │ ├── GetAllFavoriteMoviesUseCase.kt
│ │ ├── GetFavoriteMovieUseCase.kt
│ │ └── GetFilteredMoviesUseCase.kt
│ │ ├── utils
│ │ └── Constants.kt
│ │ └── gateways
│ │ └── MovieGateway.kt
└── build.gradle.kts
├── .idea
├── .gitignore
├── compiler.xml
├── vcs.xml
├── misc.xml
└── gradle.xml
├── settings.gradle.kts
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── app
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── font
│ │ │ │ ├── poppins_bold.ttf
│ │ │ │ ├── poppins_light.ttf
│ │ │ │ ├── poppins_medium.ttf
│ │ │ │ └── poppins_regular.ttf
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── values
│ │ │ │ ├── colors.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── strings.xml
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ ├── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ │ └── drawable
│ │ │ │ └── ic_launcher_background.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── ang
│ │ │ │ └── acb
│ │ │ │ └── movienight
│ │ │ │ ├── ui
│ │ │ │ ├── theme
│ │ │ │ │ ├── Shape.kt
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Theme.kt
│ │ │ │ │ └── Type.kt
│ │ │ │ ├── common
│ │ │ │ │ ├── MovieFilterMap.kt
│ │ │ │ │ ├── PagingLoadingView.kt
│ │ │ │ │ ├── LoadingBox.kt
│ │ │ │ │ ├── PagingLoadingItem.kt
│ │ │ │ │ ├── MessageBox.kt
│ │ │ │ │ ├── MovieItem.kt
│ │ │ │ │ ├── PagingErrorMessage.kt
│ │ │ │ │ ├── PagingErrorItem.kt
│ │ │ │ │ ├── Carousel.kt
│ │ │ │ │ ├── MoviePoster.kt
│ │ │ │ │ └── MovieItemDetails.kt
│ │ │ │ ├── details
│ │ │ │ │ ├── CastCard.kt
│ │ │ │ │ ├── MovieInfoHeader.kt
│ │ │ │ │ ├── CastCarousel.kt
│ │ │ │ │ ├── TrailerCarousel.kt
│ │ │ │ │ ├── MovieInfoBackdrop.kt
│ │ │ │ │ ├── CastDetailsTopBar.kt
│ │ │ │ │ ├── SimilarMoviesCarousel.kt
│ │ │ │ │ ├── CastDetailsViewModel.kt
│ │ │ │ │ ├── TrailerCard.kt
│ │ │ │ │ ├── CastProfileImage.kt
│ │ │ │ │ ├── MovieInfoPosterRow.kt
│ │ │ │ │ ├── MovieDetailsTopBar.kt
│ │ │ │ │ ├── MovieInfoRating.kt
│ │ │ │ │ ├── CastInfoAvatarRow.kt
│ │ │ │ │ ├── CastDetailsScreen.kt
│ │ │ │ │ ├── MovieDetailsViewModel.kt
│ │ │ │ │ └── MovieDetailsScreen.kt
│ │ │ │ ├── search
│ │ │ │ │ ├── SearchMoviesPagingSource.kt
│ │ │ │ │ ├── SearchResultEmpty.kt
│ │ │ │ │ ├── SearchMoviesViewModel.kt
│ │ │ │ │ ├── SearchMoviesScreen.kt
│ │ │ │ │ ├── SearchMoviesTextField.kt
│ │ │ │ │ └── SearchMoviesResults.kt
│ │ │ │ ├── filter
│ │ │ │ │ ├── FilterMoviesPagingSource.kt
│ │ │ │ │ ├── FilterMoviesViewModel.kt
│ │ │ │ │ ├── FilterMoviesTopBar.kt
│ │ │ │ │ └── FilterMoviesScreen.kt
│ │ │ │ ├── MainActivity.kt
│ │ │ │ ├── favorites
│ │ │ │ │ ├── FavoritesViewModel.kt
│ │ │ │ │ └── FavoriteMoviesScreen.kt
│ │ │ │ └── main
│ │ │ │ │ ├── MainScreen.kt
│ │ │ │ │ ├── MovieNightScreens.kt
│ │ │ │ │ ├── MoviesBottomBar.kt
│ │ │ │ │ └── MoviesNavHost.kt
│ │ │ │ ├── MovieNightApplication.kt
│ │ │ │ └── di
│ │ │ │ ├── RepoModule.kt
│ │ │ │ ├── RoomModule.kt
│ │ │ │ └── NetworkModule.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── ang
│ │ │ └── acb
│ │ │ └── movienight
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── ang
│ │ └── acb
│ │ └── movienight
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
├── .gitignore
└── build.gradle.kts
├── gradle.properties
├── .gitignore
├── README.md
├── gradlew.bat
└── gradlew
/data/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/data/consumer-rules.pro:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/domain/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/data/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "MovieNight"
2 | include(":app", ":domain", ":data")
3 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/font/poppins_bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/font/poppins_bold.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/poppins_light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/font/poppins_light.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/poppins_medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/font/poppins_medium.ttf
--------------------------------------------------------------------------------
/app/src/main/res/font/poppins_regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/font/poppins_regular.ttf
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angela-aciobanitei/kotlin-movie-night/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/Genre.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | data class Genre(
4 | val id: Long,
5 | val movieId: Long,
6 | val name: String?
7 | )
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/MovieFilter.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | enum class MovieFilter {
4 | POPULAR,
5 | TOP_RATED,
6 | NOW_PLAYING,
7 | UPCOMING
8 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/Movies.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | data class Movies(
4 | val movies: List,
5 | val currentPage: Int,
6 | val totalPages: Int,
7 | )
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/MovieDetails.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | data class MovieDetails(
4 | val movie: Movie,
5 | val genres: List,
6 | val cast: List,
7 | val trailers: List,
8 | )
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Jun 07 12:50:48 BST 2021
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-all.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/NetworkGenre.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class NetworkGenre(
6 | @SerializedName("id") val id: Long,
7 | @SerializedName("name") val name: String?
8 | )
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/NetworkVideo.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class NetworkVideo(
6 | @SerializedName("id") val id: String,
7 | @SerializedName("key") val key: String?,
8 | @SerializedName("name") val name: String?
9 | )
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/local/MovieDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 |
6 | @Database(entities = [FavoriteMovie::class], version = 2, exportSchema = false)
7 | abstract class MoviesDatabase : RoomDatabase() {
8 |
9 | abstract val movieDao: MovieDao
10 | }
11 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/NetworkCast.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class NetworkCast(
6 | @SerializedName("id") val id: Long,
7 | @SerializedName("name") val actorName: String?,
8 | @SerializedName("profile_path") val profileImagePath: String?
9 | )
--------------------------------------------------------------------------------
/domain/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | }
4 |
5 | java {
6 | sourceCompatibility = JavaVersion.VERSION_17
7 | targetCompatibility = JavaVersion.VERSION_17
8 | }
9 |
10 | dependencies {
11 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.kotlin_coroutines}")
12 | implementation("javax.inject:javax.inject:${Versions.jvm_inject}")
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes = Shapes(
8 | small = RoundedCornerShape(4.dp),
9 | medium = RoundedCornerShape(4.dp),
10 | large = RoundedCornerShape(0.dp)
11 | )
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # Gradle settings configured through the IDE *will override*
3 | # any settings specified in this file.
4 | # http://www.gradle.org/docs/current/userguide/build_environment.html
5 |
6 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
7 | android.useAndroidX=true
8 | kotlin.code.style=official
9 | android.nonTransitiveRClass=true
10 |
11 | TMDB_API_KEY=""
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/Cast.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | import com.ang.acb.movienight.domain.utils.Constants.CAST_AVATAR_URL
4 |
5 | data class Cast(
6 | val id: Long,
7 | val movieId: Long,
8 | val actorName: String?,
9 | val profileImagePath: String?
10 | ) {
11 | val profileImageUrl = if (profileImagePath != null) CAST_AVATAR_URL + profileImagePath else null
12 | }
13 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/CreditsResponse.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | /**
6 | * The response from themoviedb.org for movie credits.
7 | * See: https://developers.themoviedb.org/3/movies/get-movie-credits
8 | */
9 | data class CreditsResponse(
10 | @SerializedName("cast") val cast: List?
11 | )
12 |
--------------------------------------------------------------------------------
/app/src/test/java/com/ang/acb/movienight/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/MovieNightApplication.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 | import timber.log.Timber
6 |
7 | @HiltAndroidApp
8 | class MovieNightApplication : Application() {
9 |
10 | override fun onCreate() {
11 | super.onCreate()
12 |
13 | if (BuildConfig.DEBUG){
14 | Timber.plant(Timber.DebugTree())
15 | }
16 | }
17 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/DeleteFavoriteMovieUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.gateways.MovieGateway
4 | import javax.inject.Inject
5 |
6 | class DeleteFavoriteMovieUseCase @Inject constructor(
7 | private val movieGateway: MovieGateway,
8 | ) {
9 | suspend operator fun invoke(movieId: Long): Int {
10 | return movieGateway.deleteFavoriteMovie(movieId)
11 | }
12 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/MoviesResponse.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class MoviesResponse(
6 | @SerializedName("page") val page: Int,
7 | @SerializedName("results") val results: List,
8 | @SerializedName("total_pages") val totalPages: Int,
9 | @SerializedName("total_results") val totalResults: Int,
10 | )
11 |
12 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/VideosResponse.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | /**
6 | * The response from themoviedb.org when getting the videos associated with a specific movie.
7 | * See: https://developers.themoviedb.org/3/movies/get-movie-videos
8 | */
9 | data class VideosResponse(
10 | @SerializedName("results") val videos: List?
11 | )
12 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/MovieFilterMap.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import com.ang.acb.movienight.R
4 | import com.ang.acb.movienight.domain.entities.MovieFilter
5 |
6 | fun MovieFilter.asStringResId() = when (this) {
7 | MovieFilter.POPULAR -> R.string.filter_by_popular
8 | MovieFilter.TOP_RATED -> R.string.filter_by_top_rated
9 | MovieFilter.NOW_PLAYING -> R.string.filter_by_now_playing
10 | MovieFilter.UPCOMING -> R.string.filter_by_upcoming
11 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetPopularMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movies
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetPopularMoviesUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(page: Int): Movies {
12 | return movieGateway.getPopularMovies(page)
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/SaveFavoriteMovieUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movie
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class SaveFavoriteMovieUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 | suspend operator fun invoke(movie: Movie): Long {
11 | return movieGateway.saveFavoriteMovie(movie)
12 | }
13 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetTopRatedMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movies
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetTopRatedMoviesUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(page: Int): Movies {
12 | return movieGateway.getTopRatedMovies(page)
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetUpcomingMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movies
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetUpcomingMoviesUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(page: Int): Movies {
12 | return movieGateway.getUpcomingMovies(page)
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetCastDetailsUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.CastDetails
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetCastDetailsUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(castId: Long): CastDetails {
12 | return movieGateway.getCastDetails(castId)
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetNowPlayingMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movies
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetNowPlayingMoviesUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(page: Int): Movies {
12 | return movieGateway.getNowPlayingMovies(page)
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/SearchMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movies
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class SearchMoviesUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(query: String, page: Int): Movies {
12 | return movieGateway.searchMovies(query, page)
13 | }
14 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle
2 | .gradle
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | /local.properties
7 |
8 | # Android Studio generated files and folders
9 | captures/
10 | .externalNativeBuild/
11 | .cxx/
12 | *.apk
13 | output.json
14 |
15 | # IntelliJ
16 | *.iml
17 | .idea/
18 | gradle.xml
19 |
20 | # General
21 | .DS_Store
22 | .externalNativeBuild
23 |
24 | # Keystore files
25 | *.jks
26 | *.keystore
27 |
28 | # Google Services (e.g. APIs or Firebase)
29 | google-services.json
30 |
31 | # Android Profiling
32 | *.hprof
33 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetMovieDetailsUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.MovieDetails
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetMovieDetailsUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(movieId: Long): MovieDetails {
12 | return movieGateway.getAllMovieDetails(movieId)
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetSimilarMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movie
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import javax.inject.Inject
6 |
7 | class GetSimilarMoviesUseCase @Inject constructor(
8 | private val movieGateway: MovieGateway,
9 | ) {
10 |
11 | suspend operator fun invoke(movieId: Long): List {
12 | return movieGateway.getSimilarMovies(movieId).movies
13 | }
14 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetAllFavoriteMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movie
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import kotlinx.coroutines.flow.Flow
6 | import javax.inject.Inject
7 |
8 | class GetAllFavoriteMoviesUseCase @Inject constructor(
9 | private val movieGateway: MovieGateway,
10 | ) {
11 |
12 | operator fun invoke(): Flow> {
13 | return movieGateway.getAllFavoriteMovies()
14 | }
15 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetFavoriteMovieUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.Movie
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import kotlinx.coroutines.flow.Flow
6 | import javax.inject.Inject
7 |
8 | class GetFavoriteMovieUseCase @Inject constructor(
9 | private val movieGateway: MovieGateway,
10 | ) {
11 | operator fun invoke(movieId: Long): Flow {
12 | return movieGateway.getFavoriteMovie(movieId)
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val red200 = Color(0xfff297a2)
6 | val red300 = Color(0xffea6d7e)
7 | val red700 = Color(0xffdd0d3c)
8 | val red800 = Color(0xffd00036)
9 | val red900 = Color(0xffc20029)
10 |
11 | val midnight0 = Color(0xFFFEFEFF)
12 | val midnight50 = Color(0xFFEFF5FD)
13 | val midnight100 = Color(0xFFCAD5E2)
14 | val midnight200 = Color(0xFFADBDD2)
15 | val midnight300 = Color(0xFF98ABC6)
16 | val midnight400 = Color(0xFF7F8FA6)
17 | val midnight800 = Color(0xFF1A1E23)
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/local/MovieMappers.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import com.ang.acb.movienight.domain.entities.Movie
4 |
5 | fun FavoriteMovie.asMovie() = Movie(
6 | id = id,
7 | title = title,
8 | overview = overview,
9 | releaseDate = releaseDate,
10 | posterPath = posterPath,
11 | backdropPath = backdropPath,
12 | popularity = popularity,
13 | voteAverage = voteAverage,
14 | voteCount = voteCount,
15 | isFavorite = isFavorite,
16 | )
17 |
18 | fun List.asMovies() = this.map { it.asMovie() }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/PagingLoadingView.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.material.CircularProgressIndicator
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Alignment
7 | import androidx.compose.ui.Modifier
8 |
9 | @Composable
10 | fun PagingLoadingView(
11 | modifier: Modifier = Modifier
12 | ) {
13 | Box(
14 | modifier = modifier,
15 | contentAlignment = Alignment.Center,
16 | content = { CircularProgressIndicator() }
17 | )
18 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/di/RepoModule.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.di
2 |
3 | import com.ang.acb.movienight.data.MovieRepository
4 | import com.ang.acb.movienight.domain.gateways.MovieGateway
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import javax.inject.Singleton
10 |
11 | @Module
12 | @InstallIn(SingletonComponent::class)
13 | object RepoModule {
14 |
15 | @Provides
16 | @Singleton
17 | fun provideMovieGateway(
18 | movieRepository: MovieRepository
19 | ): MovieGateway = movieRepository
20 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/NetworkCastDetails.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class NetworkCastDetails(
6 | @SerializedName("id") val id: Long,
7 | @SerializedName("name") val name: String,
8 | @SerializedName("birthday") val birthday: String?,
9 | @SerializedName("place_of_birth") val placeOfBirth: String?,
10 | @SerializedName("biography") val biography: String?,
11 | @SerializedName("profile_path") val profilePath: String?,
12 | @SerializedName("imdb_id") val imdbId: String?,
13 | )
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/utils/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.utils
2 |
3 | object Constants {
4 | const val TMDB_BASE_URL = "https://api.themoviedb.org/3/"
5 |
6 | private const val IMAGE_BASE_URL = "https://image.tmdb.org/t/p/"
7 | private const val IMAGE_SIZE_W185 = "w185"
8 | private const val IMAGE_SIZE_W780 = "w780"
9 |
10 | const val CAST_AVATAR_URL = IMAGE_BASE_URL + IMAGE_SIZE_W185
11 | const val CAST_IMDB_URL = "https://www.imdb.com/name/"
12 | const val POSTER_URL = IMAGE_BASE_URL + IMAGE_SIZE_W185
13 | const val BACKDROP_URL = IMAGE_BASE_URL + IMAGE_SIZE_W780
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/LoadingBox.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.material.CircularProgressIndicator
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 |
10 | @Composable
11 | fun LoadingBox() {
12 | Box(
13 | contentAlignment = Alignment.Center,
14 | modifier = Modifier.fillMaxSize(),
15 | content = {
16 | CircularProgressIndicator()
17 | },
18 | )
19 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/CastDetails.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | import com.ang.acb.movienight.domain.utils.Constants.CAST_AVATAR_URL
4 | import com.ang.acb.movienight.domain.utils.Constants.CAST_IMDB_URL
5 |
6 | data class CastDetails(
7 | val id: Long,
8 | val name: String?,
9 | val birthday: String?,
10 | val placeOfBirth: String?,
11 | val biography: String?,
12 | val profileImagePath: String?,
13 | val imdbId: String?
14 | ) {
15 | val profileImageUrl = if (profileImagePath != null) CAST_AVATAR_URL + profileImagePath else null
16 | val imdbUrl = if (imdbId != null) CAST_IMDB_URL + imdbId else null
17 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | import com.ang.acb.movienight.domain.utils.Constants
4 |
5 | data class Movie(
6 | val id: Long,
7 | val title: String?,
8 | val overview: String?,
9 | val releaseDate: String?,
10 | val posterPath: String?,
11 | val backdropPath: String?,
12 | val popularity: Double?,
13 | val voteAverage: Double?,
14 | val voteCount: Int?,
15 | val isFavorite: Boolean?,
16 | ) {
17 | val posterUrl = if (posterPath != null) Constants.POSTER_URL + posterPath else null
18 | val backdropUrl = if (backdropPath != null) Constants.BACKDROP_URL + backdropPath else null
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastCard.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.material.Card
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import com.ang.acb.movienight.domain.entities.Cast
8 |
9 | @Composable
10 | fun CastCard(
11 | cast: Cast,
12 | modifier: Modifier = Modifier,
13 | onItemClick: (cast: Cast) -> Unit,
14 | ) {
15 | Card(modifier = modifier) {
16 | CastProfileImage(
17 | profileImageUrl = cast.profileImageUrl,
18 | modifier = Modifier.clickable { onItemClick(cast) },
19 | )
20 | }
21 | }
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/NetworkMovie.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | data class NetworkMovie(
6 | @SerializedName("id") val id: Long,
7 | @SerializedName("title") val title: String?,
8 | @SerializedName("overview") val overview: String?,
9 | @SerializedName("release_date") val releaseDate: String?,
10 | @SerializedName("poster_path") val posterPath: String?,
11 | @SerializedName("backdrop_path") val backdropPath: String?,
12 | @SerializedName("popularity") val popularity: Double?,
13 | @SerializedName("vote_average") val voteAverage: Double?,
14 | @SerializedName("vote_count") val voteCount: Int?
15 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/PagingLoadingItem.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.foundation.layout.wrapContentWidth
6 | import androidx.compose.material.CircularProgressIndicator
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 |
12 | @Composable
13 | fun PagingLoadingItem() {
14 | CircularProgressIndicator(
15 | modifier = Modifier
16 | .fillMaxWidth()
17 | .padding(16.dp)
18 | .wrapContentWidth(Alignment.CenterHorizontally)
19 | )
20 | }
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/ang/acb/movienight/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.ang.acb.movienight", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/data/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/local/FavoriteMovie.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 |
7 | @Entity(tableName = "movie")
8 | data class FavoriteMovie(
9 | @PrimaryKey
10 | val id: Long,
11 | val title: String?,
12 | val overview: String?,
13 | @ColumnInfo(name = "release_date")
14 | val releaseDate: String?,
15 | @ColumnInfo(name = "poster_path")
16 | val posterPath: String?,
17 | @ColumnInfo(name = "backdrop_path")
18 | val backdropPath: String?,
19 | val popularity: Double?,
20 | @ColumnInfo(name = "vote_average")
21 | val voteAverage: Double?,
22 | @ColumnInfo(name = "vote_count")
23 | val voteCount: Int?,
24 | @ColumnInfo(name = "is_favorite")
25 | val isFavorite: Boolean
26 | )
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/entities/Trailer.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.entities
2 |
3 | private const val YOUTUBE_APP_BASE_URL = "vnd.youtube:"
4 | private const val YOUTUBE_WEB_BASE_URL = "https://www.youtube.com/watch?v="
5 | private const val YOUTUBE_TRAILER_THUMBNAIL_BASE_URL = "https://img.youtube.com/vi/"
6 | private const val YOUTUBE_TRAILER_THUMBNAIL_HQ_SUFFIX = "/hqdefault.jpg"
7 |
8 | data class Trailer(
9 | val id: String,
10 | val movieId: Long,
11 | val key: String?,
12 | val name: String?
13 | ) {
14 | val youTubeThumbnailUrl = if (key != null) {
15 | YOUTUBE_TRAILER_THUMBNAIL_BASE_URL + key + YOUTUBE_TRAILER_THUMBNAIL_HQ_SUFFIX
16 | } else null
17 |
18 | val youTubeAppUrl = if (key != null) YOUTUBE_APP_BASE_URL + key else null
19 | val youTubeWebUrl = if (key != null) YOUTUBE_WEB_BASE_URL + key else null
20 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
22 |
23 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/MessageBox.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.res.stringResource
12 | import androidx.compose.ui.text.style.TextAlign
13 | import androidx.compose.ui.unit.dp
14 |
15 |
16 | @Composable
17 | fun MessageBox(
18 | @StringRes messageResId: Int
19 | ) {
20 | Box(
21 | contentAlignment = Alignment.Center,
22 | modifier = Modifier
23 | .fillMaxSize()
24 | .padding(32.dp),
25 | ) {
26 | Text(
27 | text = stringResource(id = messageResId),
28 | textAlign = TextAlign.Center,
29 | )
30 | }
31 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/usecases/GetFilteredMoviesUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.usecases
2 |
3 | import com.ang.acb.movienight.domain.entities.MovieFilter
4 | import com.ang.acb.movienight.domain.entities.Movies
5 | import javax.inject.Inject
6 |
7 | class GetFilteredMoviesUseCase @Inject constructor(
8 | private val getPopularMoviesUseCase: GetPopularMoviesUseCase,
9 | private val getTopRatedMoviesUseCase: GetTopRatedMoviesUseCase,
10 | private val getNowPlayingMoviesUseCase: GetNowPlayingMoviesUseCase,
11 | private val getUpcomingMoviesUseCase: GetUpcomingMoviesUseCase,
12 | ) {
13 |
14 | suspend operator fun invoke(filter: MovieFilter, page: Int): Movies {
15 | return when (filter) {
16 | MovieFilter.POPULAR -> getPopularMoviesUseCase(page)
17 | MovieFilter.TOP_RATED -> getTopRatedMoviesUseCase(page)
18 | MovieFilter.NOW_PLAYING -> getNowPlayingMoviesUseCase(page)
19 | MovieFilter.UPCOMING -> getUpcomingMoviesUseCase(page)
20 | }
21 | }
22 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieInfoHeader.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.text.TextStyle
10 | import androidx.compose.ui.text.font.FontWeight
11 | import androidx.compose.ui.unit.dp
12 | import androidx.compose.ui.unit.sp
13 | import com.ang.acb.movienight.ui.theme.moviesFontFamily
14 |
15 | @Composable
16 | fun MovieInfoHeader(title: String) {
17 | Box(
18 | modifier = Modifier
19 | .fillMaxWidth()
20 | .padding(horizontal = 16.dp, vertical = 8.dp)
21 | ) {
22 | Text(
23 | text = title,
24 | style = TextStyle(
25 | fontFamily = moviesFontFamily,
26 | fontWeight = FontWeight.Medium,
27 | fontSize = 16.sp,
28 | letterSpacing = 0.15.sp,
29 | )
30 | )
31 | }
32 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastCarousel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.aspectRatio
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.unit.dp
9 | import com.ang.acb.movienight.domain.entities.Cast
10 | import com.ang.acb.movienight.ui.common.Carousel
11 |
12 | @Composable
13 | fun CastCarousel(
14 | cast: List,
15 | onItemClick: (cast: Cast) -> Unit,
16 | modifier: Modifier = Modifier,
17 | ) {
18 | Carousel(
19 | items = cast,
20 | contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
21 | itemSpacing = 4.dp,
22 | modifier = modifier
23 | ) { item, padding ->
24 | CastCard(
25 | cast = item,
26 | onItemClick = onItemClick,
27 | modifier = Modifier
28 | .padding(padding)
29 | .fillParentMaxHeight()
30 | .aspectRatio(2 / 3f),
31 | )
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/TrailerCarousel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.aspectRatio
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.unit.dp
9 | import com.ang.acb.movienight.domain.entities.Trailer
10 | import com.ang.acb.movienight.ui.common.Carousel
11 |
12 | @Composable
13 | fun TrailerCarousel(
14 | trailers: List,
15 | modifier: Modifier = Modifier,
16 | onItemClick: (trailer: Trailer) -> Unit,
17 | ) {
18 | Carousel(
19 | items = trailers,
20 | contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
21 | itemSpacing = 4.dp,
22 | modifier = modifier
23 | ) { item, padding ->
24 | TrailerCard(
25 | trailer = item,
26 | modifier = Modifier
27 | .padding(padding)
28 | .fillParentMaxHeight()
29 | .aspectRatio(16 / 9f),
30 | onItemClick = onItemClick
31 | )
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieInfoBackdrop.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.aspectRatio
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.layout.ContentScale
10 | import androidx.compose.ui.platform.LocalContext
11 | import coil.compose.AsyncImage
12 | import coil.request.ImageRequest
13 |
14 | @Composable
15 | fun MovieBackdropImage(
16 | backdropUrl: String,
17 | ) {
18 | Box(
19 | modifier = Modifier
20 | .fillMaxWidth()
21 | .aspectRatio(16f / 9),
22 | contentAlignment = Alignment.Center,
23 | ) {
24 | AsyncImage(
25 | model = ImageRequest.Builder(LocalContext.current)
26 | .data(backdropUrl)
27 | .crossfade(500)
28 | .build(),
29 | contentDescription = null,
30 | contentScale = ContentScale.Crop,
31 | modifier = Modifier.matchParentSize(),
32 | )
33 | }
34 | }
--------------------------------------------------------------------------------
/domain/src/main/java/com/ang/acb/movienight/domain/gateways/MovieGateway.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.domain.gateways
2 |
3 | import com.ang.acb.movienight.domain.entities.CastDetails
4 | import com.ang.acb.movienight.domain.entities.Movie
5 | import com.ang.acb.movienight.domain.entities.MovieDetails
6 | import com.ang.acb.movienight.domain.entities.Movies
7 | import kotlinx.coroutines.flow.Flow
8 |
9 | interface MovieGateway {
10 | suspend fun getPopularMovies(page: Int): Movies
11 | suspend fun getTopRatedMovies(page: Int): Movies
12 | suspend fun getNowPlayingMovies(page: Int): Movies
13 | suspend fun getUpcomingMovies(page: Int): Movies
14 | suspend fun searchMovies(query: String, page: Int): Movies
15 | suspend fun getAllMovieDetails(movieId: Long): MovieDetails
16 | suspend fun getSimilarMovies(movieId: Long): Movies
17 | suspend fun getCastDetails(castId: Long): CastDetails
18 |
19 | suspend fun saveFavoriteMovie(movie: Movie): Long
20 | suspend fun updateFavoriteFlag(movieId: String, isFavorite: Boolean)
21 | suspend fun deleteFavoriteMovie(movieId: Long): Int
22 | suspend fun deleteAllFavoriteMovies()
23 | fun getFavoriteMovie(movieId: Long): Flow
24 | fun getAllFavoriteMovies(): Flow>
25 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/di/RoomModule.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.di
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import com.ang.acb.movienight.data.source.local.MovieDao
6 | import com.ang.acb.movienight.data.source.local.MoviesDatabase
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import kotlinx.coroutines.Dispatchers
13 | import javax.inject.Singleton
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | object RoomModule {
18 |
19 | @Singleton
20 | @Provides
21 | fun provideDataBase(@ApplicationContext context: Context): MoviesDatabase {
22 | return Room.databaseBuilder(
23 | context.applicationContext,
24 | MoviesDatabase::class.java,
25 | "movies.db"
26 | )
27 | .fallbackToDestructiveMigration()
28 | .build()
29 | }
30 |
31 | @Singleton
32 | @Provides
33 | fun provideMovieDao(database: MoviesDatabase): MovieDao {
34 | return database.movieDao
35 | }
36 |
37 | @Singleton
38 | @Provides
39 | fun provideIoDispatcher() = Dispatchers.IO
40 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
17 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material.MaterialTheme
5 | import androidx.compose.material.darkColors
6 | import androidx.compose.material.lightColors
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.graphics.Color
9 |
10 | private val LightThemeColors = lightColors(
11 | primary = red700,
12 | primaryVariant = red900,
13 | onPrimary = Color.White,
14 | secondary = red700,
15 | secondaryVariant = red900,
16 | onSecondary = Color.White,
17 | error = red800
18 | )
19 |
20 | private val DarkThemeColors = darkColors(
21 | primary = red300,
22 | primaryVariant = red700,
23 | onPrimary = Color.Black,
24 | secondary = red300,
25 | onSecondary = Color.Black,
26 | error = red200
27 | )
28 |
29 | @Composable
30 | fun MovieNightTheme(
31 | darkTheme: Boolean = isSystemInDarkTheme(),
32 | content: @Composable () -> Unit
33 | ) {
34 | val colors = if (darkTheme) DarkThemeColors else LightThemeColors
35 |
36 | MaterialTheme(
37 | colors = colors,
38 | typography = moviesTypography,
39 | shapes = Shapes,
40 | content = content
41 | )
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/MovieItem.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.aspectRatio
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.unit.dp
11 | import com.ang.acb.movienight.domain.entities.Movie
12 |
13 | @Composable
14 | fun MovieItem(
15 | movie: Movie,
16 | onMovieClick: (movieId: Long) -> Unit,
17 | modifier: Modifier = Modifier,
18 | ) {
19 | Row(
20 | modifier = modifier
21 | .clickable { onMovieClick(movie.id) }
22 | .padding(horizontal = 16.dp, vertical = 8.dp)
23 | .fillMaxWidth()
24 | ) {
25 | MoviePoster(
26 | posterUrl = movie.posterUrl,
27 | modifier = Modifier
28 | .weight(1f)
29 | .aspectRatio(2 / 3f)
30 | )
31 |
32 | MovieItemDetails(
33 | movie = movie,
34 | modifier = Modifier
35 | .weight(4f)
36 | .padding(horizontal = 16.dp)
37 | )
38 | }
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/PagingErrorMessage.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material.Button
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Alignment
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.res.stringResource
11 | import androidx.compose.ui.text.style.TextAlign
12 | import androidx.compose.ui.unit.dp
13 | import com.ang.acb.movienight.R
14 |
15 | @Composable
16 | fun PagingErrorMessage(
17 | modifier: Modifier = Modifier,
18 | message: String,
19 | onRetryClick: () -> Unit
20 | ) {
21 | Column(
22 | modifier = modifier.padding(16.dp),
23 | verticalArrangement = Arrangement.Center,
24 | horizontalAlignment = Alignment.CenterHorizontally,
25 | ) {
26 | Text(
27 | text = message,
28 | style = MaterialTheme.typography.subtitle2,
29 | textAlign = TextAlign.Center,
30 | color = MaterialTheme.colors.error
31 | )
32 |
33 | Spacer(modifier = Modifier.height(16.dp))
34 |
35 | Button(onClick = onRetryClick) {
36 | Text(text = stringResource(R.string.try_again_button_label))
37 | }
38 | }
39 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/PagingErrorItem.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material.Button
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.material.Text
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.res.stringResource
13 | import androidx.compose.ui.unit.dp
14 | import com.ang.acb.movienight.R
15 |
16 | @Composable
17 | fun PagingErrorItem(
18 | message: String,
19 | modifier: Modifier = Modifier,
20 | onRetryClick: () -> Unit
21 | ) {
22 | Row(
23 | modifier = modifier.padding(16.dp),
24 | horizontalArrangement = Arrangement.SpaceBetween,
25 | verticalAlignment = Alignment.CenterVertically
26 | ) {
27 | Text(
28 | text = message,
29 | maxLines = 1,
30 | modifier = Modifier.weight(1f),
31 | style = MaterialTheme.typography.subtitle2,
32 | color = MaterialTheme.colors.error,
33 | )
34 | Button(onClick = onRetryClick) {
35 | Text(text = stringResource(R.string.try_again_button_label))
36 | }
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/local/MovieDao.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Insert
5 | import androidx.room.OnConflictStrategy
6 | import androidx.room.Query
7 | import kotlinx.coroutines.flow.Flow
8 |
9 | /**
10 | * Interface for database access on [FavoriteMovie] related operations.
11 | */
12 | @Dao
13 | interface MovieDao {
14 | @Insert(onConflict = OnConflictStrategy.REPLACE)
15 | suspend fun insertMovie(movie: FavoriteMovie): Long
16 |
17 | // When the return type is Flow, querying an empty table throws a null pointer exception.
18 | // When the return type is Flow, querying an empty table emits a null value.
19 | @Query("SELECT * FROM movie WHERE movie.id = :movieId")
20 | fun getMovie(movieId: Long): Flow
21 |
22 | // When the return type is Flow>, querying an empty table emits an empty list.
23 | @Query("SELECT * FROM movie WHERE is_favorite = 1 ORDER BY title")
24 | fun getAllFavoriteMovies(): Flow>
25 |
26 | @Query("UPDATE movie SET is_favorite = :isFavorite WHERE id = :movieId")
27 | suspend fun updateFavorite(movieId: String, isFavorite: Boolean)
28 |
29 | @Query("DELETE FROM movie WHERE id = :movieId")
30 | suspend fun deleteMovieById(movieId: Long): Int
31 |
32 | @Query("DELETE FROM movie")
33 | suspend fun deleteMovies()
34 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/response/NetworkMovieDetails.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote.response
2 |
3 | import com.google.gson.annotations.SerializedName
4 |
5 | /**
6 | * The response from themoviedb.org for movie details.
7 | *
8 | * Note: Since the "movie" method supports the query parameter
9 | * append_to_response, we can use it to issue multiple requests in
10 | * order to include the movie credits, videos and reviews in our response.
11 | *
12 | * See: https://developers.themoviedb.org/3/movies/get-movie-details
13 | * See: https://developers.themoviedb.org/3/getting-started/append-to-response
14 | */
15 | data class NetworkMovieDetails(
16 | @SerializedName("id") val id: Long,
17 | @SerializedName("title") val title: String,
18 | @SerializedName("overview") val overview: String?,
19 | @SerializedName("release_date") val releaseDate: String?,
20 | @SerializedName("poster_path") val posterPath: String?,
21 | @SerializedName("backdrop_path") val backdropPath: String?,
22 | @SerializedName("popularity") val popularity: Double?,
23 | @SerializedName("vote_average") val voteAverage: Double?,
24 | @SerializedName("vote_count") val voteCount: Int?,
25 | @SerializedName("genres") val genres: List?,
26 | @SerializedName("credits") val creditsResponse: CreditsResponse,
27 | @SerializedName("videos") val videosResponse: VideosResponse,
28 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/search/SearchMoviesPagingSource.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.search
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import com.ang.acb.movienight.domain.entities.Movie
6 | import com.ang.acb.movienight.domain.usecases.SearchMoviesUseCase
7 | import timber.log.Timber
8 |
9 | private const val STARTING_PAGE_INDEX = 1
10 |
11 | class SearchMoviesPagingSource(
12 | private val query: String,
13 | private val searchMoviesUseCase: SearchMoviesUseCase,
14 | ) : PagingSource() {
15 |
16 | override suspend fun load(params: LoadParams): LoadResult {
17 | val page = params.key ?: STARTING_PAGE_INDEX
18 |
19 | return try {
20 | val response = searchMoviesUseCase(query, page)
21 |
22 | LoadResult.Page(
23 | data = response.movies,
24 | prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
25 | // Avoid infinite loading when the search response total_pages is 0
26 | nextKey = if (page == response.totalPages || response.totalPages == 0) null else page + 1
27 | )
28 | } catch (e: Exception) {
29 | Timber.e(e)
30 | LoadResult.Error(e)
31 | }
32 | }
33 |
34 | override fun getRefreshKey(state: PagingState): Int? {
35 | return state.anchorPosition
36 | }
37 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/filter/FilterMoviesPagingSource.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.filter
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import com.ang.acb.movienight.domain.entities.Movie
6 | import com.ang.acb.movienight.domain.entities.MovieFilter
7 | import com.ang.acb.movienight.domain.usecases.GetFilteredMoviesUseCase
8 | import timber.log.Timber
9 |
10 | private const val STARTING_PAGE_INDEX = 1
11 |
12 | class FilterMoviesPagingSource(
13 | private val filter: MovieFilter,
14 | private val getFilteredMoviesUseCase: GetFilteredMoviesUseCase,
15 | ) : PagingSource() {
16 |
17 | override suspend fun load(params: LoadParams): LoadResult {
18 | val page = params.key ?: STARTING_PAGE_INDEX
19 |
20 | return try {
21 | val response = getFilteredMoviesUseCase(filter, page)
22 |
23 | LoadResult.Page(
24 | data = response.movies,
25 | prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
26 | nextKey = if (page == response.totalPages || response.totalPages == 0) null else page + 1
27 | )
28 |
29 | } catch (e: Exception) {
30 | Timber.e(e)
31 | LoadResult.Error(e)
32 | }
33 | }
34 |
35 | override fun getRefreshKey(state: PagingState): Int? {
36 | return state.anchorPosition
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastDetailsTopBar.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.material.*
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.filled.ArrowBack
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.res.stringResource
10 | import androidx.compose.ui.text.style.TextOverflow
11 | import androidx.compose.ui.unit.dp
12 | import com.ang.acb.movienight.R
13 |
14 | @Composable
15 | fun CastDetailsTopBar(
16 | upPressed: () -> Unit
17 | ) {
18 | TopAppBar(
19 | backgroundColor = MaterialTheme.colors.surface.copy(alpha = 0.95f),
20 | contentColor = MaterialTheme.colors.onSurface,
21 | title = {
22 | Text(
23 | modifier = Modifier.padding(end = 32.dp),
24 | text = stringResource(R.string.cast_details_topbar_label),
25 | maxLines = 1,
26 | overflow = TextOverflow.Ellipsis
27 | )
28 | },
29 | navigationIcon = {
30 | IconButton(onClick = upPressed) {
31 | Icon(
32 | imageVector = Icons.Default.ArrowBack,
33 | contentDescription = stringResource(R.string.topbar_up_button_content_description),
34 | )
35 | }
36 | }
37 | )
38 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/local/LocalMovieDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import kotlinx.coroutines.CoroutineDispatcher
4 | import kotlinx.coroutines.Dispatchers
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.withContext
7 | import javax.inject.Inject
8 |
9 | class LocalMovieDataSource @Inject constructor(
10 | private val movieDao: MovieDao,
11 | private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
12 | ) {
13 |
14 | suspend fun saveFavoriteMovie(movie: FavoriteMovie): Long {
15 | return withContext(ioDispatcher) {
16 | movieDao.insertMovie(movie)
17 | }
18 | }
19 |
20 | fun getFavoriteMovie(movieId: Long): Flow {
21 | return movieDao.getMovie(movieId)
22 | }
23 |
24 | fun getAllFavoriteMovies(): Flow> {
25 | return movieDao.getAllFavoriteMovies()
26 | }
27 |
28 | suspend fun updateFavoriteFlag(movieId: String, isFavorite: Boolean) {
29 | withContext(ioDispatcher) {
30 | movieDao.updateFavorite(movieId, isFavorite)
31 | }
32 | }
33 |
34 | suspend fun deleteFavoriteMovie(movieId: Long): Int {
35 | return withContext(ioDispatcher) {
36 | movieDao.deleteMovieById(movieId)
37 | }
38 | }
39 |
40 | suspend fun deleteAllFavoriteMovies() {
41 | withContext(ioDispatcher) {
42 | movieDao.deleteMovies()
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/search/SearchResultEmpty.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.search
2 |
3 | import androidx.compose.foundation.layout.fillMaxWidth
4 | import androidx.compose.foundation.layout.padding
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.res.stringResource
10 | import androidx.compose.ui.text.SpanStyle
11 | import androidx.compose.ui.text.buildAnnotatedString
12 | import androidx.compose.ui.text.font.FontWeight
13 | import androidx.compose.ui.text.style.TextAlign
14 | import androidx.compose.ui.text.withStyle
15 | import androidx.compose.ui.unit.dp
16 | import com.ang.acb.movienight.R
17 |
18 | @Composable
19 | fun SearchResultEmptyMessage(
20 | searchTerm: String,
21 | ) {
22 | Text(modifier = Modifier
23 | .fillMaxWidth()
24 | .padding(16.dp),
25 | textAlign = TextAlign.Center,
26 | text = buildAnnotatedString {
27 | append(stringResource(R.string.search_no_results_start))
28 | withStyle(
29 | style = SpanStyle(
30 | fontWeight = FontWeight.Medium,
31 | color = MaterialTheme.colors.primary
32 | )
33 | ) {
34 | append(" $searchTerm")
35 | }
36 | append(". ")
37 | append(stringResource(R.string.search_no_results_end))
38 | }
39 | )
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/SimilarMoviesCarousel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.aspectRatio
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.material.Card
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.unit.dp
12 | import com.ang.acb.movienight.domain.entities.Movie
13 | import com.ang.acb.movienight.ui.common.Carousel
14 | import com.ang.acb.movienight.ui.common.MoviePoster
15 |
16 | @Composable
17 | fun SimilarMoviesCarousel(
18 | movies: List,
19 | modifier: Modifier = Modifier,
20 | onItemClick: (movieId: Long) -> Unit,
21 | ) {
22 | Carousel(
23 | items = movies,
24 | contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
25 | itemSpacing = 4.dp,
26 | modifier = modifier
27 | ) { item, padding ->
28 | Card(modifier = modifier) {
29 | Box(modifier = Modifier.clickable { onItemClick(item.id) }) {
30 | MoviePoster(
31 | posterUrl = item.posterUrl,
32 | modifier = Modifier
33 | .padding(padding)
34 | .fillParentMaxHeight()
35 | .aspectRatio(2 / 3f),
36 | )
37 | }
38 | }
39 | }
40 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/filter/FilterMoviesViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.filter
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import androidx.paging.Pager
8 | import androidx.paging.PagingConfig
9 | import androidx.paging.PagingData
10 | import com.ang.acb.movienight.domain.entities.Movie
11 | import com.ang.acb.movienight.domain.entities.MovieFilter
12 | import com.ang.acb.movienight.domain.usecases.GetFilteredMoviesUseCase
13 | import com.ang.acb.movienight.ui.common.asStringResId
14 | import dagger.hilt.android.lifecycle.HiltViewModel
15 | import kotlinx.coroutines.FlowPreview
16 | import kotlinx.coroutines.flow.Flow
17 | import javax.inject.Inject
18 |
19 | @HiltViewModel
20 | class FilterMoviesViewModel @Inject constructor(
21 | private val getFilteredMoviesUseCase: GetFilteredMoviesUseCase,
22 | ) : ViewModel() {
23 |
24 | var filter by mutableStateOf(MovieFilter.POPULAR)
25 |
26 | @FlowPreview
27 | fun getPagedMovies(filter: MovieFilter): Flow> {
28 | return Pager(
29 | config = PagingConfig(enablePlaceholders = false, pageSize = 50),
30 | pagingSourceFactory = {
31 | FilterMoviesPagingSource(
32 | getFilteredMoviesUseCase = getFilteredMoviesUseCase,
33 | filter = filter
34 | )
35 | }
36 | ).flow
37 | }
38 |
39 | fun getFilterLabel() = filter.asStringResId()
40 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/RemoteMovieDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote
2 |
3 | import com.ang.acb.movienight.domain.entities.CastDetails
4 | import com.ang.acb.movienight.domain.entities.MovieDetails
5 | import com.ang.acb.movienight.domain.entities.Movies
6 | import javax.inject.Inject
7 |
8 | class RemoteMovieDataSource @Inject constructor(
9 | private val movieService: MovieService
10 | ) {
11 |
12 | suspend fun getPopularMovies(page: Int): Movies {
13 | return movieService.getPopularMovies(page).asMovies()
14 | }
15 |
16 | suspend fun getTopRatedMovies(page: Int): Movies {
17 | return movieService.getTopRatedMovies(page).asMovies()
18 | }
19 |
20 | suspend fun getNowPlayingMovies(page: Int): Movies {
21 | return movieService.getNowPlayingMovies(page).asMovies()
22 | }
23 |
24 | suspend fun getUpcomingMovies(page: Int): Movies {
25 | return movieService.getUpcomingMovies(page).asMovies()
26 | }
27 |
28 | suspend fun searchMovies(query: String, page: Int): Movies {
29 | return movieService.searchMovies(query, page).asMovies()
30 | }
31 |
32 | suspend fun getAllMovieDetails(movieId: Long): MovieDetails {
33 | return movieService.getAllMovieDetails(movieId).asMovieDetails()
34 | }
35 |
36 | suspend fun getSimilarMovies(movieId: Long): Movies {
37 | return movieService.getSimilarMovies(movieId).asMovies()
38 | }
39 |
40 | suspend fun getCastDetails(castId: Long): CastDetails {
41 | return movieService.getCastDetails(castId).asCastDetails()
42 | }
43 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.compose.animation.ExperimentalAnimationApi
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.runtime.SideEffect
9 | import androidx.compose.ui.ExperimentalComposeUiApi
10 | import androidx.compose.ui.graphics.Color
11 | import com.ang.acb.movienight.ui.main.MainScreen
12 | import com.ang.acb.movienight.ui.theme.MovieNightTheme
13 | import com.google.accompanist.insets.ProvideWindowInsets
14 | import com.google.accompanist.systemuicontroller.rememberSystemUiController
15 | import dagger.hilt.android.AndroidEntryPoint
16 | import kotlinx.coroutines.FlowPreview
17 |
18 | @ExperimentalAnimationApi
19 | @ExperimentalComposeUiApi
20 | @FlowPreview
21 | @AndroidEntryPoint
22 | class MainActivity : ComponentActivity() {
23 |
24 | override fun onCreate(savedInstanceState: Bundle?) {
25 | super.onCreate(savedInstanceState)
26 |
27 | setContent {
28 | // Update the system bars to be translucent
29 | val systemUiController = rememberSystemUiController()
30 | val useDarkIcons = MaterialTheme.colors.isLight
31 | SideEffect {
32 | systemUiController.setStatusBarColor(Color.Transparent, darkIcons = useDarkIcons)
33 | }
34 |
35 | ProvideWindowInsets {
36 | MovieNightTheme {
37 | MainScreen()
38 | }
39 | }
40 | }
41 | }
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastDetailsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.SavedStateHandle
7 | import androidx.lifecycle.ViewModel
8 | import androidx.lifecycle.viewModelScope
9 | import com.ang.acb.movienight.R
10 | import com.ang.acb.movienight.domain.entities.CastDetails
11 | import com.ang.acb.movienight.domain.usecases.GetCastDetailsUseCase
12 | import dagger.hilt.android.lifecycle.HiltViewModel
13 | import kotlinx.coroutines.launch
14 | import timber.log.Timber
15 | import javax.inject.Inject
16 |
17 | @HiltViewModel
18 | class CastDetailsViewModel @Inject constructor(
19 | savedStateHandle: SavedStateHandle,
20 | private val getCastDetailsUseCase: GetCastDetailsUseCase,
21 | ) : ViewModel() {
22 |
23 | private val movieId: Long = savedStateHandle.get("castId")!!
24 |
25 | var castDetails: CastDetails? by mutableStateOf(null)
26 | var isLoading: Boolean by mutableStateOf(false)
27 | var errorMessage: Int? by mutableStateOf(null)
28 |
29 | init {
30 | getMovieDetails(movieId)
31 | }
32 |
33 | private fun getMovieDetails(movieId: Long) {
34 | viewModelScope.launch {
35 | isLoading = true
36 | try {
37 | castDetails = getCastDetailsUseCase(movieId)
38 | } catch (e: Exception) {
39 | Timber.e(e)
40 | errorMessage = R.string.get_movie_details_error_message
41 | }
42 | isLoading = false
43 | }
44 | }
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/favorites/FavoritesViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.favorites
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import com.ang.acb.movienight.R
9 | import com.ang.acb.movienight.domain.entities.Movie
10 | import com.ang.acb.movienight.domain.usecases.GetAllFavoriteMoviesUseCase
11 | import dagger.hilt.android.lifecycle.HiltViewModel
12 | import kotlinx.coroutines.flow.catch
13 | import kotlinx.coroutines.flow.collect
14 | import kotlinx.coroutines.launch
15 | import timber.log.Timber
16 | import javax.inject.Inject
17 |
18 | @HiltViewModel
19 | class FavoritesViewModel @Inject constructor(
20 | private val getAllFavoriteMoviesUseCase: GetAllFavoriteMoviesUseCase,
21 | ) : ViewModel() {
22 |
23 | var movies: List? by mutableStateOf(null)
24 | var isLoading: Boolean by mutableStateOf(false)
25 | var errorMessage: Int? by mutableStateOf(null)
26 |
27 | init {
28 | getFavoriteMovies()
29 | }
30 |
31 | private fun getFavoriteMovies() {
32 | viewModelScope.launch {
33 | isLoading = true
34 | getAllFavoriteMoviesUseCase()
35 | .catch {
36 | isLoading = false
37 | errorMessage = R.string.get_favorites_error_message
38 | Timber.e(it)
39 | }
40 | .collect {
41 | isLoading = false
42 | movies = it
43 | }
44 | }
45 | }
46 | }
--------------------------------------------------------------------------------
/data/src/androidTest/java/com/ang/acb/movienight/data/utils/MainCoroutineRule.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.utils
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.TestCoroutineDispatcher
6 | import kotlinx.coroutines.test.TestCoroutineScope
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.setMain
9 | import org.junit.rules.TestWatcher
10 | import org.junit.runner.Description
11 |
12 | /**
13 | * See: https://github.com/googlecodelabs/android-testing/tree/end_codelab_3
14 | *
15 | * Sets the main coroutines dispatcher to a [TestCoroutineScope] for unit testing. A
16 | * [TestCoroutineScope] provides control over the execution of coroutines.
17 | *
18 | * Declare it as a JUnit Rule:
19 | *
20 | * ```
21 | * @get:Rule
22 | * var mainCoroutineRule = TestCoroutineRule()
23 | * ```
24 | *
25 | * Use it directly as a [TestCoroutineScope]:
26 | *
27 | * ```
28 | * mainCoroutineRule.pauseDispatcher()
29 | * mainCoroutineRule.resumeDispatcher()
30 | * mainCoroutineRule.runBlockingTest { }
31 | * ```
32 | */
33 | @ExperimentalCoroutinesApi
34 | class MainCoroutineRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
35 | TestWatcher(), TestCoroutineScope by TestCoroutineScope(dispatcher) {
36 |
37 | override fun starting(description: Description?) {
38 | super.starting(description)
39 | Dispatchers.setMain(dispatcher)
40 | }
41 |
42 | override fun finished(description: Description?) {
43 | super.finished(description)
44 | cleanupTestCoroutines()
45 | Dispatchers.resetMain()
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/TrailerCard.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.material.Card
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.layout.ContentScale
12 | import androidx.compose.ui.platform.LocalContext
13 | import coil.compose.AsyncImage
14 | import coil.request.ImageRequest
15 | import com.ang.acb.movienight.domain.entities.Trailer
16 | import com.ang.acb.movienight.ui.theme.midnight50
17 |
18 | @Composable
19 | fun TrailerCard(
20 | trailer: Trailer,
21 | modifier: Modifier = Modifier,
22 | onItemClick: (trailer: Trailer) -> Unit,
23 | ) {
24 | Card(modifier = modifier) {
25 | Box(
26 | modifier = Modifier
27 | .clickable { onItemClick(trailer) }
28 | .background(
29 | shape = MaterialTheme.shapes.medium,
30 | color = midnight50,
31 | ),
32 | contentAlignment = Alignment.Center,
33 | ) {
34 | // TODO Use a placeholder on error
35 | AsyncImage(
36 | model = ImageRequest.Builder(LocalContext.current)
37 | .data(trailer.youTubeThumbnailUrl)
38 | .build(),
39 | contentDescription = null,
40 | contentScale = ContentScale.Crop,
41 | modifier = Modifier.matchParentSize(),
42 | )
43 | }
44 | }
45 | }
46 |
47 |
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/MovieService.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote
2 |
3 | import com.ang.acb.movienight.data.source.remote.response.MoviesResponse
4 | import com.ang.acb.movienight.data.source.remote.response.NetworkCastDetails
5 | import com.ang.acb.movienight.data.source.remote.response.NetworkMovieDetails
6 | import retrofit2.http.GET
7 | import retrofit2.http.Path
8 | import retrofit2.http.Query
9 |
10 | interface MovieService {
11 | @GET("movie/popular")
12 | suspend fun getPopularMovies(@Query("page") page: Int): MoviesResponse
13 |
14 | @GET("movie/top_rated")
15 | suspend fun getTopRatedMovies(@Query("page") page: Int): MoviesResponse
16 |
17 | @GET("movie/now_playing")
18 | suspend fun getNowPlayingMovies(@Query("page") page: Int): MoviesResponse
19 |
20 | @GET("movie/upcoming")
21 | suspend fun getUpcomingMovies(@Query("page") page: Int): MoviesResponse
22 |
23 | @GET("search/movie")
24 | suspend fun searchMovies(
25 | @Query("query") query: String,
26 | @Query("page") page: Int
27 | ): MoviesResponse
28 |
29 | /**
30 | * Get detailed information about a movie.
31 | * Use query parameter "append_to_response" to issue multiple requests.
32 | * https://developers.themoviedb.org/3/getting-started/append-to-response
33 | */
34 | @GET("movie/{id}?append_to_response=videos,credits")
35 | suspend fun getAllMovieDetails(@Path("id") id: Long): NetworkMovieDetails
36 |
37 | @GET("person/{person_id}")
38 | suspend fun getCastDetails(@Path("person_id") id: Long): NetworkCastDetails
39 |
40 | @GET("movie/{movie_id}/recommendations")
41 | suspend fun getSimilarMovies(@Path("movie_id") id: Long): MoviesResponse
42 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/main/MainScreen.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.main
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.fillMaxSize
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.Scaffold
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.ui.ExperimentalComposeUiApi
11 | import androidx.compose.ui.Modifier
12 | import androidx.navigation.compose.currentBackStackEntryAsState
13 | import androidx.navigation.compose.rememberNavController
14 | import kotlinx.coroutines.FlowPreview
15 |
16 | @ExperimentalAnimationApi
17 | @ExperimentalComposeUiApi
18 | @FlowPreview
19 | @Composable
20 | fun MainScreen() {
21 |
22 | val navController = rememberNavController()
23 | val rootScreens = listOf(RootScreen.Discover, RootScreen.Search, RootScreen.Favorites)
24 |
25 | val mainRoutes = listOf(
26 | LeafScreen.Discover.createRoute(RootScreen.Discover),
27 | LeafScreen.Search.createRoute(RootScreen.Search),
28 | LeafScreen.Favorites.createRoute(RootScreen.Favorites)
29 | )
30 |
31 | val currentBackStackEntry by navController.currentBackStackEntryAsState()
32 | val currentRoute = currentBackStackEntry?.destination?.route
33 | val showBottomBar = currentRoute in mainRoutes
34 |
35 | Scaffold(
36 | bottomBar = {
37 | if (showBottomBar) MoviesBottomBar(navController, rootScreens)
38 | },
39 | content = { innerPadding ->
40 | Box(Modifier.fillMaxSize().padding(paddingValues = innerPadding)) {
41 | MoviesNavHost(navController)
42 | }
43 | }
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/Carousel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.PaddingValues
4 | import androidx.compose.foundation.layout.calculateEndPadding
5 | import androidx.compose.foundation.layout.calculateStartPadding
6 | import androidx.compose.foundation.lazy.LazyItemScope
7 | import androidx.compose.foundation.lazy.LazyRow
8 | import androidx.compose.foundation.lazy.items
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.platform.LocalLayoutDirection
13 | import androidx.compose.ui.unit.Dp
14 | import androidx.compose.ui.unit.dp
15 |
16 | @Composable
17 | fun Carousel(
18 | items: List,
19 | modifier: Modifier = Modifier,
20 | contentPadding: PaddingValues = PaddingValues(0.dp),
21 | itemSpacing: Dp = 0.dp,
22 | verticalAlignment: Alignment.Vertical = Alignment.Top,
23 | itemContent: @Composable LazyItemScope.(T, PaddingValues) -> Unit
24 | ) {
25 | val halfSpacing = itemSpacing / 2
26 | val spacingContent = PaddingValues(halfSpacing, 0.dp, halfSpacing, 0.dp)
27 | val layoutDir = LocalLayoutDirection.current
28 |
29 | LazyRow(
30 | modifier = modifier,
31 | contentPadding = PaddingValues(
32 | start = (contentPadding.calculateStartPadding(layoutDir) - halfSpacing).coerceAtLeast(0.dp),
33 | top = contentPadding.calculateTopPadding(),
34 | end = (contentPadding.calculateEndPadding(layoutDir) - halfSpacing).coerceAtLeast(0.dp),
35 | bottom = contentPadding.calculateBottomPadding(),
36 | ),
37 | verticalAlignment = verticalAlignment
38 | ) {
39 | items(items) { item ->
40 | itemContent(item, spacingContent)
41 | }
42 | }
43 | }
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.aar
4 | *.ap_
5 | *.aab
6 |
7 | # Files for the ART/Dalvik VM
8 | *.dex
9 |
10 | # Java class files
11 | *.class
12 |
13 | # Generated files
14 | bin/
15 | gen/
16 | out/
17 |
18 | # Uncomment the following line in case you need and you don't have the release build type files in your app
19 | # release/
20 |
21 | # Gradle files
22 | .gradle/
23 | gradle.properties
24 | build/
25 |
26 | # Local configuration file (sdk path, etc)
27 | local.properties
28 |
29 | # Proguard folder generated by Eclipse
30 | proguard/
31 |
32 | # Log Files
33 | *.log
34 |
35 | # Android Studio Navigation editor temp files
36 | .navigation/
37 |
38 | # Android Studio captures folder
39 | captures/
40 |
41 | # IntelliJ
42 | *.iml
43 | .idea/workspace.xml
44 | .idea/tasks.xml
45 | .idea/gradle.xml
46 | .idea/assetWizardSettings.xml
47 | .idea/dictionaries
48 | .idea/libraries
49 | .idea/jarRepositories.xml
50 | # Android Studio 3 in .gitignore file.
51 | .idea/caches
52 | .idea/modules.xml
53 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
54 | .idea/navEditor.xml
55 |
56 | # Keystore files
57 | # Uncomment the following lines if you do not want to check your keystore files in.
58 | #*.jks
59 | #*.keystore
60 |
61 | # External native build folder generated in Android Studio 2.2 and later
62 | .externalNativeBuild
63 | .cxx/
64 |
65 | # Google Services (e.g. APIs or Firebase)
66 | # google-services.json
67 |
68 | # Freeline
69 | freeline.py
70 | freeline/
71 | freeline_project_description.json
72 |
73 | # fastlane
74 | fastlane/report.xml
75 | fastlane/Preview.html
76 | fastlane/screenshots
77 | fastlane/test_output
78 | fastlane/readme.md
79 |
80 | # Version control
81 | vcs.xml
82 |
83 | # lint
84 | lint/intermediates/
85 | lint/generated/
86 | lint/outputs/
87 | lint/tmp/
88 | # lint/reports/
89 |
90 | # Android Profiling
91 | *.hprof
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastProfileImage.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.material.Icon
6 | import androidx.compose.material.MaterialTheme
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.AccountCircle
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.draw.scale
14 | import androidx.compose.ui.layout.ContentScale
15 | import androidx.compose.ui.platform.LocalContext
16 | import coil.compose.AsyncImage
17 | import coil.request.ImageRequest
18 | import com.ang.acb.movienight.ui.theme.midnight50
19 |
20 | @Composable
21 | fun CastProfileImage(
22 | profileImageUrl: String?,
23 | modifier: Modifier = Modifier,
24 | ) {
25 | Box(modifier = modifier) {
26 | // TODO Use placeholder on error
27 | AsyncImage(
28 | model = ImageRequest.Builder(LocalContext.current)
29 | .data(profileImageUrl)
30 | .crossfade(500)
31 | .build(),
32 | contentDescription = null,
33 | contentScale = ContentScale.Crop,
34 | modifier = Modifier
35 | .matchParentSize()
36 | .clip(MaterialTheme.shapes.medium),
37 | )
38 | }
39 | }
40 |
41 | @Composable
42 | private fun PlaceholderProfileImage(
43 | modifier: Modifier = Modifier,
44 | ) {
45 | Box(
46 | modifier = modifier.background(
47 | shape = MaterialTheme.shapes.medium,
48 | color = midnight50,
49 | ),
50 | contentAlignment = Alignment.Center,
51 | ) {
52 | Icon(
53 | imageVector = Icons.Default.AccountCircle,
54 | contentDescription = null,
55 | tint = MaterialTheme.colors.primary,
56 | modifier = Modifier.scale(2f)
57 | )
58 | }
59 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/MoviePoster.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.material.Icon
6 | import androidx.compose.material.MaterialTheme
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.MovieFilter
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Alignment
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.clip
13 | import androidx.compose.ui.draw.scale
14 | import androidx.compose.ui.layout.ContentScale
15 | import androidx.compose.ui.platform.LocalContext
16 | import coil.compose.AsyncImage
17 | import coil.request.ImageRequest
18 | import com.ang.acb.movienight.ui.theme.midnight50
19 |
20 | @Composable
21 | fun MoviePoster(
22 | posterUrl: String?,
23 | modifier: Modifier = Modifier,
24 | ) {
25 | Box(
26 | modifier = modifier.clip(MaterialTheme.shapes.medium),
27 | contentAlignment = Alignment.Center,
28 | ) {
29 | // TODO Use a placeholder on error
30 | AsyncImage(
31 | model = ImageRequest.Builder(LocalContext.current)
32 | .data(posterUrl)
33 | .crossfade(500)
34 | .build(),
35 | contentDescription = null,
36 | contentScale = ContentScale.Crop,
37 | modifier = Modifier
38 | .matchParentSize()
39 | .clip(MaterialTheme.shapes.medium),
40 | )
41 | }
42 | }
43 |
44 | @Composable
45 | fun MoviePosterPlaceholder(
46 | modifier: Modifier = Modifier,
47 | ) {
48 | Box(
49 | modifier = modifier.background(
50 | shape = MaterialTheme.shapes.medium,
51 | color = midnight50,
52 | ),
53 | contentAlignment = Alignment.Center,
54 | ) {
55 | Icon(
56 | imageVector = Icons.Default.MovieFilter,
57 | contentDescription = null,
58 | tint = MaterialTheme.colors.primary,
59 | modifier = Modifier.scale(1.5f)
60 | )
61 | }
62 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/search/SearchMoviesViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.search
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.ViewModel
7 | import androidx.lifecycle.viewModelScope
8 | import androidx.paging.Pager
9 | import androidx.paging.PagingConfig
10 | import androidx.paging.PagingData
11 | import com.ang.acb.movienight.domain.entities.Movie
12 | import com.ang.acb.movienight.domain.usecases.SearchMoviesUseCase
13 | import dagger.hilt.android.lifecycle.HiltViewModel
14 | import kotlinx.coroutines.FlowPreview
15 | import kotlinx.coroutines.Job
16 | import kotlinx.coroutines.flow.Flow
17 | import kotlinx.coroutines.flow.MutableStateFlow
18 | import kotlinx.coroutines.flow.collectLatest
19 | import kotlinx.coroutines.flow.debounce
20 | import kotlinx.coroutines.launch
21 | import javax.inject.Inject
22 |
23 | @FlowPreview
24 | @HiltViewModel
25 | class SearchMoviesViewModel @Inject constructor(
26 | private val searchMoviesUseCase: SearchMoviesUseCase,
27 | ) : ViewModel() {
28 |
29 | val searchQuery = MutableStateFlow("")
30 | var searchResults: Flow>? by mutableStateOf(null)
31 | private var currentJob: Job? = null
32 |
33 | init {
34 | viewModelScope.launch {
35 | searchQuery.debounce(300)
36 | .collectLatest { query ->
37 | currentJob?.cancel()
38 | currentJob = launch {
39 | searchResults = if (query.isNotBlank()) search(query.trim()) else null
40 | }
41 | }
42 | }
43 | }
44 |
45 | private fun search(query: String): Flow> {
46 | return Pager(
47 | config = PagingConfig(enablePlaceholders = false, pageSize = 50),
48 | pagingSourceFactory = {
49 | SearchMoviesPagingSource(
50 | query = query,
51 | searchMoviesUseCase = searchMoviesUseCase,
52 | )
53 | }
54 | ).flow
55 | }
56 |
57 | fun updateQuery(query: String) {
58 | searchQuery.value = query
59 | }
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/main/MovieNightScreens.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.main
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.material.icons.Icons
5 | import androidx.compose.material.icons.filled.Favorite
6 | import androidx.compose.material.icons.filled.MovieFilter
7 | import androidx.compose.material.icons.filled.Search
8 | import androidx.compose.ui.graphics.vector.ImageVector
9 | import com.ang.acb.movienight.R
10 |
11 | sealed class RootScreen(
12 | val route: String,
13 | @StringRes val label: Int,
14 | val icon: ImageVector,
15 | ) {
16 | object Discover : RootScreen(
17 | route = "discover",
18 | label = R.string.bottom_nav_item_label_discover,
19 | icon = Icons.Default.MovieFilter,
20 | )
21 |
22 | object Search : RootScreen(
23 | route = "search",
24 | label = R.string.bottom_nav_item_label_search,
25 | icon = Icons.Default.Search,
26 | )
27 |
28 | object Favorites : RootScreen(
29 | route = "favorites",
30 | label = R.string.bottom_nav_item_label_favorites,
31 | icon = Icons.Default.Favorite,
32 | )
33 | }
34 |
35 | sealed class LeafScreen(val route: String) {
36 |
37 | object Discover : LeafScreen("discover") {
38 | fun createRoute(root: RootScreen) = "${root.route}/$route"
39 | }
40 |
41 | object Search : LeafScreen("search") {
42 | fun createRoute(root: RootScreen) = "${root.route}/$route"
43 | }
44 |
45 | object Favorites : LeafScreen("favorites") {
46 | fun createRoute(root: RootScreen) = "${root.route}/$route"
47 | }
48 |
49 | object MovieDetails : LeafScreen("movie/{movieId}") {
50 | fun createRoute(root: RootScreen) = "${root.route}/$route"
51 |
52 | fun createRoute(rootScreen: RootScreen, movieId: Long): String {
53 | return "${rootScreen.route}/movie/$movieId"
54 | }
55 | }
56 |
57 | object CastDetails : LeafScreen("movie/{movieId}/cast/{castId}") {
58 | fun createRoute(root: RootScreen) = "${root.route}/$route"
59 |
60 | fun createRoute(rootScreen: RootScreen, movieId: Long, castId: Long): String {
61 | return "${rootScreen.route}/movie/$movieId/cast/$castId"
62 | }
63 | }
64 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Movies
4 | Discover
5 | Search
6 | Favorites
7 | Woops, there was an error. Try again!
8 | Try again
9 | Top bar up button
10 |
11 |
12 | Popular
13 | Top Rated
14 | Now Playing
15 | Upcoming
16 |
17 |
18 | Search movies
19 | Search movies
20 | Clear text
21 | No results for
22 | Try searching for something different.
23 |
24 |
25 | Your Favorites
26 | Could not fetch your favorite movies
27 | You have no favorite movies yet.
28 |
29 |
30 | Could not fetch movie details
31 | Release date: %s
32 | Vote average: %.1f
33 | Vote count: %d
34 | Cast
35 | Overview
36 | Trailers
37 | Biography
38 | See also
39 | About
40 | See IMDb page
41 | Birthday: %s
42 | Birthplace: %s
43 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieInfoPosterRow.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.foundation.shape.RoundedCornerShape
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Surface
7 | import androidx.compose.material.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Modifier
10 | import androidx.compose.ui.graphics.Color
11 | import androidx.compose.ui.unit.dp
12 | import com.ang.acb.movienight.domain.entities.MovieDetails
13 | import com.ang.acb.movienight.ui.common.MoviePoster
14 | import com.google.accompanist.flowlayout.FlowRow
15 |
16 | @Composable
17 | fun MovieInfoPosterRow(
18 | movieDetails: MovieDetails,
19 | ) {
20 | Row(
21 | modifier = Modifier
22 | .padding(16.dp)
23 | .fillMaxWidth()
24 | ) {
25 | MoviePoster(
26 | posterUrl = movieDetails.movie.posterUrl,
27 | modifier = Modifier
28 | .weight(2f)
29 | .aspectRatio(2 / 3f)
30 | )
31 |
32 | MovieInfo(
33 | movieDetails = movieDetails,
34 | modifier = Modifier
35 | .weight(3f)
36 | .padding(start = 16.dp)
37 | )
38 | }
39 | }
40 |
41 | @Composable
42 | private fun MovieInfo(
43 | movieDetails: MovieDetails,
44 | modifier: Modifier = Modifier,
45 | ) {
46 | Column(modifier.fillMaxWidth()) {
47 | Text(
48 | modifier = Modifier.fillMaxWidth(),
49 | text = movieDetails.movie.title ?: "",
50 | style = MaterialTheme.typography.h6,
51 | )
52 |
53 | Spacer(modifier = Modifier.height(32.dp))
54 |
55 | FlowRow(
56 | mainAxisSpacing = 4.dp,
57 | crossAxisSpacing = 8.dp,
58 | ) {
59 | movieDetails.genres.forEach {
60 | if (it.name != null) GenreChip(genreName = it.name!!)
61 | }
62 | }
63 | }
64 | }
65 |
66 | @Composable
67 | private fun GenreChip(genreName: String) {
68 | Surface(
69 | elevation = 4.dp,
70 | shape = RoundedCornerShape(70),
71 | color = MaterialTheme.colors.primary
72 | ) {
73 | Text(
74 | text = genreName,
75 | style = MaterialTheme.typography.body2,
76 | color = Color.White,
77 | modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
78 | )
79 | }
80 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieDetailsTopBar.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.foundation.layout.size
5 | import androidx.compose.material.*
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.ArrowBack
8 | import androidx.compose.material.icons.filled.Favorite
9 | import androidx.compose.material.icons.filled.FavoriteBorder
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.res.stringResource
13 | import androidx.compose.ui.text.style.TextOverflow
14 | import androidx.compose.ui.unit.dp
15 | import com.ang.acb.movienight.R
16 |
17 | @Composable
18 | fun MovieDetailsTopBar(
19 | title: String,
20 | isFavorite: Boolean,
21 | isFavoriteLoading: Boolean,
22 | onFavoriteClicked: () -> Unit,
23 | upPressed: () -> Unit
24 | ) {
25 | TopAppBar(
26 | backgroundColor = MaterialTheme.colors.surface.copy(alpha = 0.95f),
27 | contentColor = MaterialTheme.colors.onSurface,
28 | title = {
29 | Text(
30 | text = title,
31 | maxLines = 1,
32 | overflow = TextOverflow.Ellipsis
33 | )
34 | },
35 | navigationIcon = {
36 | IconButton(onClick = upPressed) {
37 | Icon(
38 | imageVector = Icons.Default.ArrowBack,
39 | contentDescription = stringResource(R.string.topbar_up_button_content_description),
40 | )
41 | }
42 | },
43 | actions = {
44 | if (isFavoriteLoading) {
45 | CircularProgressIndicator(
46 | modifier = Modifier
47 | .padding(8.dp)
48 | .size(24.dp),
49 | color = MaterialTheme.colors.primary,
50 | strokeWidth = 2.dp
51 | )
52 | } else {
53 | IconButton(
54 | onClick = onFavoriteClicked,
55 | content = {
56 | Icon(
57 | imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
58 | contentDescription = null,
59 | tint = MaterialTheme.colors.primary
60 | )
61 | }
62 | )
63 | }
64 | }
65 | )
66 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/common/MovieItemDetails.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.common
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material.MaterialTheme
5 | import androidx.compose.material.Text
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.compose.ui.res.stringResource
9 | import androidx.compose.ui.text.TextStyle
10 | import androidx.compose.ui.text.font.FontWeight
11 | import androidx.compose.ui.text.style.TextOverflow
12 | import androidx.compose.ui.unit.dp
13 | import androidx.compose.ui.unit.sp
14 | import com.ang.acb.movienight.R
15 | import com.ang.acb.movienight.domain.entities.Movie
16 | import com.ang.acb.movienight.ui.theme.moviesFontFamily
17 |
18 | @Composable
19 | fun MovieItemDetails(
20 | movie: Movie,
21 | modifier: Modifier = Modifier,
22 | ) {
23 | Column(modifier.fillMaxWidth()) {
24 | val titleTextStyle = TextStyle(
25 | fontFamily = moviesFontFamily,
26 | fontWeight = FontWeight.Medium,
27 | fontSize = 16.sp,
28 | letterSpacing = 0.15.sp,
29 | )
30 | val subtitleTextStyle = MaterialTheme.typography.caption
31 | Text(
32 | modifier = Modifier.padding(bottom = 8.dp),
33 | text = movie.title ?: "",
34 | maxLines = 2,
35 | overflow = TextOverflow.Ellipsis,
36 | style = titleTextStyle
37 | )
38 |
39 | Spacer(modifier = Modifier.width(16.dp))
40 |
41 | if (movie.releaseDate != null) {
42 | Text(
43 | text = stringResource(
44 | id = R.string.movie_details_release_date,
45 | formatArgs = arrayOf(movie.releaseDate!!)
46 | ),
47 | style = subtitleTextStyle
48 | )
49 | }
50 |
51 | if (movie.voteAverage != null) {
52 | Text(
53 | text = stringResource(
54 | id = R.string.movie_details_vote_average,
55 | formatArgs = arrayOf(movie.voteAverage!!)
56 | ),
57 | style = subtitleTextStyle
58 | )
59 | }
60 |
61 | if (movie.voteCount != null) {
62 | Text(
63 | text = stringResource(
64 | id = R.string.movie_details_vote_count,
65 | formatArgs = arrayOf(movie.voteCount!!)
66 | ),
67 | style = subtitleTextStyle
68 | )
69 | }
70 | }
71 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/favorites/FavoriteMoviesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.favorites
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.foundation.lazy.itemsIndexed
6 | import androidx.compose.material.MaterialTheme
7 | import androidx.compose.material.Scaffold
8 | import androidx.compose.material.Text
9 | import androidx.compose.material.TopAppBar
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.res.stringResource
13 | import androidx.compose.ui.unit.dp
14 | import androidx.hilt.navigation.compose.hiltViewModel
15 | import com.ang.acb.movienight.R
16 | import com.ang.acb.movienight.ui.common.LoadingBox
17 | import com.ang.acb.movienight.ui.common.MessageBox
18 | import com.ang.acb.movienight.ui.common.MovieItem
19 |
20 | @Composable
21 | internal fun FavoriteMoviesScreen(
22 | viewModel: FavoritesViewModel = hiltViewModel(),
23 | openMovieDetails: (movieId: Long) -> Unit,
24 | ) {
25 | Scaffold(
26 | topBar = {
27 | TopAppBar(
28 | backgroundColor = MaterialTheme.colors.surface.copy(alpha = 0.95f),
29 | contentColor = MaterialTheme.colors.onSurface,
30 | title = { Text(stringResource(R.string.favorites_topbar_label)) },
31 | )
32 | },
33 | content = { padding ->
34 | if (viewModel.isLoading) {
35 | LoadingBox()
36 | } else {
37 | if (viewModel.errorMessage != null) {
38 | MessageBox(messageResId = viewModel.errorMessage!!)
39 | } else if (viewModel.movies?.isEmpty() == true) {
40 | MessageBox(messageResId = R.string.no_favorites_hint_message)
41 | } else {
42 | LazyColumn(contentPadding = padding) {
43 | val items = viewModel.movies ?: emptyList()
44 |
45 | itemsIndexed(items) { index, item ->
46 | val bottomPadding = if (index == items.size - 1) 64 else 0
47 | MovieItem(
48 | movie = item,
49 | onMovieClick = { movieId -> openMovieDetails(movieId) },
50 | modifier = Modifier.padding(bottom = bottomPadding.dp),
51 | )
52 | }
53 | }
54 | }
55 | }
56 | }
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieInfoRating.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.layout.*
4 | import androidx.compose.material.Divider
5 | import androidx.compose.material.Icon
6 | import androidx.compose.material.Text
7 | import androidx.compose.material.icons.Icons
8 | import androidx.compose.material.icons.filled.DateRange
9 | import androidx.compose.material.icons.filled.People
10 | import androidx.compose.material.icons.filled.StarBorder
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.text.style.TextAlign
15 | import androidx.compose.ui.unit.dp
16 | import com.ang.acb.movienight.domain.entities.Movie
17 |
18 | @Composable
19 | fun MovieInfoRating(
20 | movie: Movie
21 | ) {
22 | Column(modifier = Modifier.fillMaxWidth()) {
23 | Divider()
24 |
25 | Row(
26 | modifier = Modifier
27 | .padding(16.dp)
28 | .fillMaxWidth(),
29 | horizontalArrangement = Arrangement.SpaceEvenly,
30 | verticalAlignment = Alignment.Bottom,
31 | ) {
32 |
33 | if (movie.releaseDate.isNullOrEmpty().not()) {
34 | Icon(imageVector = Icons.Default.DateRange, contentDescription = null)
35 | Text(
36 | text = movie.releaseDate!!,
37 | textAlign = TextAlign.Center,
38 | modifier = Modifier.padding(horizontal = 4.dp)
39 | )
40 | Spacer(modifier = Modifier.weight(1f))
41 | }
42 |
43 | if (movie.voteCount != null) {
44 | Icon(imageVector = Icons.Default.People, contentDescription = null)
45 | Text(
46 | text = "${movie.voteCount} votes",
47 | textAlign = TextAlign.Center,
48 | modifier = Modifier.padding(horizontal = 4.dp)
49 | )
50 | Spacer(modifier = Modifier.weight(1f))
51 | }
52 |
53 | if (movie.voteAverage != null) {
54 | Icon(imageVector = Icons.Default.StarBorder, contentDescription = null)
55 | Text(
56 | text = "${movie.voteAverage}",
57 | textAlign = TextAlign.Center,
58 | modifier = Modifier.padding(horizontal = 4.dp)
59 | )
60 | Spacer(modifier = Modifier.weight(1f))
61 | }
62 | }
63 |
64 | Divider()
65 | }
66 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Movie Night
2 |
3 | [](https://www.codacy.com/gh/angela-aciobanitei/kotlin-movie-night/dashboard?utm_source=github.com&utm_medium=referral&utm_content=angela-aciobanitei/kotlin-movie-night&utm_campaign=Badge_Grade) [](https://codebeat.co/projects/github-com-angela-aciobanitei-kotlin-movie-night-master)
4 |
5 | A movies app that fetches data using [TMDB](https://www.themoviedb.org/documentation/api?language=en-US), allowing users to filter the movies by most popular, top rated, or similar criteria. Users can also search for a movie, find more details about a movie, or mark their favourite movies.
6 |
7 | The app uses clean architecture, MVVM, Kotlin coroutines, Flow and the latest Jetpack libraries, including Compose.
8 |
9 | ## Core Libraries
10 | * [Hilt](https://dagger.dev/hilt/) for dependency injection
11 | * [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) and [Flow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/) for asynchronous programming
12 | * [Compose](https://developer.android.com/jetpack/compose/documentation) for building the UI layer
13 | * [Navigation](https://developer.android.com/jetpack/compose/navigation) to navigate between composables
14 | * [Accompanist Glide](https://google.github.io/accompanist/glide/) for image loading
15 | * [Paging 3](https://developer.android.com/topic/libraries/architecture/paging/v3-overview) for data loading
16 | * [Retrofit 2](https://github.com/square/retrofit) and [OkHttp](https://github.com/square/okhttp) for networking
17 | * [Gson](https://github.com/google/gson) for parsing JSON
18 | * [Room](https://developer.android.com/topic/libraries/architecture/room) for data persistence
19 |
20 |
21 | ## Installing the App
22 |
23 | * Clone this repository
24 | ```
25 | git https://github.com/angela-aciobanitei/kotlin-movie-night.git
26 | ```
27 | * Go to [The Movie Database](https://developers.themoviedb.org/3/getting-started/introduction) page and register for an API key.
28 | * Import the project in Android Studio and add the TMDB API Key inside the `gradle.properties` file.
29 |
30 | ```
31 | TMDB_API_KEY="Your API Key Here"
32 | ```
33 |
34 | ## Demo
35 |
36 | https://user-images.githubusercontent.com/37955938/124139986-046f3c80-da80-11eb-8cb7-51fece9b9362.mp4
37 |
38 | https://user-images.githubusercontent.com/37955938/124140010-0a651d80-da80-11eb-979c-311fdb499fad.mp4
39 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/main/MoviesBottomBar.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.main
2 |
3 | import androidx.compose.material.BottomNavigation
4 | import androidx.compose.material.BottomNavigationItem
5 | import androidx.compose.material.Icon
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.getValue
9 | import androidx.compose.ui.res.stringResource
10 | import androidx.navigation.NavDestination.Companion.hierarchy
11 | import androidx.navigation.NavGraph.Companion.findStartDestination
12 | import androidx.navigation.NavHostController
13 | import androidx.navigation.compose.currentBackStackEntryAsState
14 |
15 | @Composable
16 | fun MoviesBottomBar(
17 | navController: NavHostController,
18 | items: List
19 | ) {
20 | BottomNavigation {
21 | // See: https://developer.android.com/jetpack/compose/navigation#bottom-nav
22 | val currentBackStackEntry by navController.currentBackStackEntryAsState()
23 | val currentDestination = currentBackStackEntry?.destination
24 |
25 | items.forEach { screen ->
26 | BottomNavigationItem(
27 | icon = { Icon(imageVector = screen.icon, contentDescription = null) },
28 | label = { Text(stringResource(screen.label)) },
29 | // The selected state of each BottomNavigationItem can then be determined by
30 | // comparing the item's route with the route of the current destination and
31 | // its parent destinations (to handle cases when you are using nested navigation)
32 | // via the hierarchy helper method.
33 | selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
34 | onClick = {
35 | navController.navigate(screen.route) {
36 | // Pop up to the start destination of the graph to
37 | // avoid building up a large stack of destinations
38 | // on the back stack as users select items
39 | popUpTo(navController.graph.findStartDestination().id) {
40 | saveState = true
41 | }
42 | // Avoid multiple copies of the same destination when re-selecting the same item
43 | launchSingleTop = true
44 | // Restore state when re-selecting a previously selected item
45 | restoreState = true
46 | }
47 | }
48 | )
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastInfoAvatarRow.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.material.MaterialTheme
6 | import androidx.compose.material.Text
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.res.stringResource
10 | import androidx.compose.ui.unit.dp
11 | import com.ang.acb.movienight.R
12 | import com.ang.acb.movienight.domain.entities.CastDetails
13 |
14 | @Composable
15 | fun CastInfoAvatarRow(
16 | cast: CastDetails,
17 | openImdb: (imdbUrl: String) -> Unit,
18 | ) {
19 | Row(
20 | modifier = Modifier
21 | .padding(16.dp)
22 | .fillMaxWidth()
23 | ) {
24 | CastProfileImage(
25 | profileImageUrl = cast.profileImageUrl,
26 | modifier = Modifier
27 | .weight(2f)
28 | .aspectRatio(2 / 3f)
29 | )
30 |
31 | Column(
32 | modifier = Modifier
33 | .fillMaxWidth()
34 | .weight(3f)
35 | ) {
36 | if (cast.name.isNullOrEmpty().not()) {
37 | MovieInfoHeader(title = cast.name!!)
38 | }
39 |
40 | if (cast.birthday.isNullOrEmpty().not()) {
41 | Text(
42 | text = stringResource(
43 | id = R.string.cast_details_birthday,
44 | formatArgs = arrayOf(cast.birthday!!)
45 | ),
46 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
47 | )
48 | }
49 |
50 | if (cast.placeOfBirth.isNullOrEmpty().not()) {
51 | Text(
52 | text = stringResource(
53 | id = R.string.cast_details_birthplace,
54 | formatArgs = arrayOf(cast.placeOfBirth!!)
55 | ),
56 | modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
57 | )
58 | }
59 |
60 | if (cast.imdbUrl.isNullOrEmpty().not()) {
61 | Text(
62 | text = stringResource(R.string.cast_details_open_imdb_page),
63 | color = MaterialTheme.colors.primary,
64 | style = MaterialTheme.typography.subtitle2,
65 | modifier = Modifier
66 | .clickable { openImdb(cast.imdbUrl!!) }
67 | .padding(16.dp)
68 | )
69 | }
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/filter/FilterMoviesTopBar.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.filter
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.material.*
5 | import androidx.compose.material.icons.Icons
6 | import androidx.compose.material.icons.filled.Sort
7 | import androidx.compose.runtime.*
8 | import androidx.compose.ui.res.stringResource
9 | import com.ang.acb.movienight.domain.entities.MovieFilter
10 | import com.ang.acb.movienight.ui.common.asStringResId
11 |
12 | @Composable
13 | fun FilterMoviesTopBar(
14 | filterLabel: Int,
15 | onFilterChanged: (filter: MovieFilter) -> Unit
16 | ) {
17 | TopAppBar(
18 | backgroundColor = MaterialTheme.colors.surface.copy(alpha = 0.95f),
19 | contentColor = MaterialTheme.colors.onSurface,
20 | title = { Text(text = stringResource(filterLabel)) },
21 | actions = { FilterMoviesMenu(onFilterChanged) }
22 | )
23 | }
24 |
25 | @Composable
26 | fun FilterMoviesMenu(
27 | onFilterChanged: (filter: MovieFilter) -> Unit,
28 | ) {
29 | var expanded by remember { mutableStateOf(false) }
30 |
31 | Box {
32 | IconButton(onClick = { expanded = true }) {
33 | Icon(
34 | imageVector = Icons.Default.Sort,
35 | contentDescription = null,
36 | )
37 | }
38 |
39 | DropdownMenu(
40 | expanded = expanded,
41 | onDismissRequest = { expanded = false }
42 | ) {
43 | FilterMenuItem(
44 | filter = MovieFilter.POPULAR,
45 | onClick = {
46 | onFilterChanged(MovieFilter.POPULAR)
47 | expanded = false
48 | },
49 | )
50 |
51 | FilterMenuItem(
52 | filter = MovieFilter.TOP_RATED,
53 | onClick = {
54 | onFilterChanged(MovieFilter.TOP_RATED)
55 | expanded = false
56 | },
57 | )
58 |
59 | FilterMenuItem(
60 | filter = MovieFilter.NOW_PLAYING,
61 | onClick = {
62 | onFilterChanged(MovieFilter.NOW_PLAYING)
63 | expanded = false
64 | },
65 | )
66 |
67 | FilterMenuItem(
68 | filter = MovieFilter.UPCOMING,
69 | onClick = {
70 | onFilterChanged(MovieFilter.UPCOMING)
71 | expanded = false
72 | },
73 | )
74 | }
75 | }
76 | }
77 |
78 | @Composable
79 | fun FilterMenuItem(
80 | filter: MovieFilter,
81 | onClick: () -> Unit,
82 | ) {
83 | DropdownMenuItem(onClick = onClick) {
84 | Text(text = stringResource(id = filter.asStringResId()))
85 | }
86 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/search/SearchMoviesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.search
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.material.MaterialTheme
8 | import androidx.compose.material.Scaffold
9 | import androidx.compose.material.Text
10 | import androidx.compose.material.TopAppBar
11 | import androidx.compose.runtime.*
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.ExperimentalComposeUiApi
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.platform.LocalSoftwareKeyboardController
16 | import androidx.compose.ui.res.stringResource
17 | import androidx.compose.ui.text.input.TextFieldValue
18 | import androidx.hilt.navigation.compose.hiltViewModel
19 | import com.ang.acb.movienight.R
20 | import kotlinx.coroutines.FlowPreview
21 |
22 | @ExperimentalComposeUiApi
23 | @ExperimentalAnimationApi
24 | @FlowPreview
25 | @Composable
26 | fun SearchMoviesScreen(
27 | viewModel: SearchMoviesViewModel = hiltViewModel(),
28 | openMovieDetails: (movieId: Long) -> Unit
29 | ) {
30 | val keyboardController = LocalSoftwareKeyboardController.current
31 | var query by remember { mutableStateOf(TextFieldValue(viewModel.searchQuery.value)) }
32 |
33 | Scaffold(
34 | topBar = {
35 | TopAppBar(
36 | backgroundColor = MaterialTheme.colors.surface.copy(alpha = 0.95f),
37 | contentColor = MaterialTheme.colors.onSurface,
38 | title = { Text(text = stringResource(R.string.search_movies_topbar_label)) },
39 | )
40 | },
41 | content = { padding ->
42 | Column(
43 | modifier = Modifier
44 | .fillMaxWidth()
45 | .padding(padding),
46 | horizontalAlignment = Alignment.CenterHorizontally,
47 | ) {
48 | SearchMoviesTextField(
49 | value = query,
50 | onValueChange = { value ->
51 | query = value
52 | viewModel.updateQuery(value.text)
53 | },
54 | )
55 |
56 | viewModel.searchResults?.let {
57 | SearchMoviesResults(
58 | searchTerm = query.text,
59 | searchResults = it,
60 | onItemClick = { movieId ->
61 | keyboardController?.hide()
62 | openMovieDetails(movieId)
63 | }
64 | )
65 | }
66 | }
67 | }
68 | )
69 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/source/remote/RemoteMovieMappers.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote
2 |
3 | import com.ang.acb.movienight.data.source.remote.response.MoviesResponse
4 | import com.ang.acb.movienight.data.source.remote.response.NetworkCastDetails
5 | import com.ang.acb.movienight.data.source.remote.response.NetworkMovie
6 | import com.ang.acb.movienight.data.source.remote.response.NetworkMovieDetails
7 | import com.ang.acb.movienight.domain.entities.*
8 |
9 | fun MoviesResponse.asMovies() = Movies(
10 | movies = results.map { it.asMovie() },
11 | currentPage = page,
12 | totalPages = totalPages,
13 | )
14 |
15 | fun NetworkMovie.asMovie() = Movie(
16 | id = id,
17 | title = title,
18 | overview = overview,
19 | releaseDate = releaseDate,
20 | posterPath = posterPath,
21 | backdropPath = backdropPath,
22 | popularity = popularity,
23 | voteAverage = voteAverage,
24 | voteCount = voteCount,
25 | isFavorite = null,
26 | )
27 |
28 | fun NetworkMovieDetails.asMovie(): Movie {
29 | return Movie(
30 | id = id,
31 | title = title,
32 | overview = overview,
33 | releaseDate = releaseDate,
34 | posterPath = posterPath,
35 | backdropPath = backdropPath,
36 | popularity = popularity,
37 | voteAverage = voteAverage,
38 | voteCount = voteCount,
39 | isFavorite = null,
40 | )
41 | }
42 |
43 | fun NetworkMovieDetails.asGenres(): List {
44 | return genres?.map {
45 | Genre(
46 | id = it.id,
47 | movieId = id,
48 | name = it.name,
49 | )
50 | }?.toList() ?: emptyList()
51 | }
52 |
53 | fun NetworkMovieDetails.asCast(): List {
54 | return creditsResponse.cast?.map {
55 | Cast(
56 | id = it.id,
57 | movieId = id,
58 | actorName = it.actorName,
59 | profileImagePath = it.profileImagePath,
60 | )
61 | }?.toList() ?: emptyList()
62 | }
63 |
64 | fun NetworkMovieDetails.asVideos(): List {
65 | return videosResponse.videos?.map {
66 | Trailer(
67 | id = it.id,
68 | movieId = id,
69 | key = it.key,
70 | name = it.name,
71 | )
72 | }?.toList() ?: emptyList()
73 | }
74 |
75 | fun NetworkMovieDetails.asMovieDetails(): MovieDetails {
76 | return MovieDetails(
77 | movie = asMovie(),
78 | genres = asGenres(),
79 | cast = asCast(),
80 | trailers = asVideos(),
81 | )
82 | }
83 |
84 | fun NetworkCastDetails.asCastDetails(): CastDetails {
85 | return CastDetails(
86 | id = id,
87 | name = name,
88 | biography = biography,
89 | birthday = birthday,
90 | placeOfBirth = placeOfBirth,
91 | profileImagePath = profilePath,
92 | imdbId = imdbId,
93 | )
94 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.di
2 |
3 | import com.ang.acb.movienight.BuildConfig
4 | import com.ang.acb.movienight.data.source.remote.MovieService
5 | import com.ang.acb.movienight.domain.utils.Constants.TMDB_BASE_URL
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.components.SingletonComponent
10 | import okhttp3.Interceptor
11 | import okhttp3.OkHttpClient
12 | import okhttp3.logging.HttpLoggingInterceptor
13 | import retrofit2.Retrofit
14 | import retrofit2.converter.gson.GsonConverterFactory
15 | import retrofit2.converter.scalars.ScalarsConverterFactory
16 | import javax.inject.Singleton
17 |
18 | @Module
19 | @InstallIn(SingletonComponent::class)
20 | object NetworkModule {
21 |
22 | @Singleton
23 | @Provides
24 | fun provideAuthInterceptor(): Interceptor {
25 | return Interceptor { chain: Interceptor.Chain ->
26 | val initialRequest = chain.request()
27 |
28 | val newUrl = initialRequest.url.newBuilder()
29 | .addQueryParameter("api_key", BuildConfig.TMDB_API_KEY)
30 | .build()
31 |
32 | val newRequest = initialRequest.newBuilder()
33 | .url(newUrl)
34 | .build()
35 |
36 | chain.proceed(newRequest)
37 | }
38 | }
39 |
40 | @Singleton
41 | @Provides
42 | fun provideLoggingInterceptor(): HttpLoggingInterceptor {
43 | // Retrofit completely relies on OkHttp for any network operation.
44 | // Since logging isn’t integrated by default anymore in Retrofit 2,
45 | // we'll use a logging interceptor for OkHttp.
46 | return HttpLoggingInterceptor().apply {
47 | level = when {
48 | BuildConfig.DEBUG -> HttpLoggingInterceptor.Level.BODY
49 | else -> HttpLoggingInterceptor.Level.NONE
50 | }
51 | }
52 | }
53 |
54 | @Singleton
55 | @Provides
56 | fun provideOkHttpClient(
57 | authInterceptor: Interceptor,
58 | loggingInterceptor: HttpLoggingInterceptor
59 | ): OkHttpClient {
60 | // Build the OkHttpClient with the logging and the auth interceptors.
61 | return OkHttpClient.Builder()
62 | .addInterceptor(authInterceptor)
63 | .addInterceptor(loggingInterceptor)
64 | .build()
65 | }
66 |
67 | @Provides
68 | @Singleton
69 | fun provideRetrofit(
70 | client: OkHttpClient
71 | ): Retrofit = Retrofit.Builder()
72 | .baseUrl(TMDB_BASE_URL)
73 | .client(client)
74 | .addConverterFactory(ScalarsConverterFactory.create())
75 | .addConverterFactory(GsonConverterFactory.create())
76 | .build()
77 |
78 | @Provides
79 | @Singleton
80 | fun provideMovieService(
81 | retrofit: Retrofit
82 | ): MovieService = retrofit.create(MovieService::class.java)
83 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/CastDetailsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import android.content.ActivityNotFoundException
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.net.Uri
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.rememberScrollState
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material.Scaffold
14 | import androidx.compose.material.Text
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.platform.LocalContext
18 | import androidx.compose.ui.res.stringResource
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.unit.dp
21 | import androidx.hilt.navigation.compose.hiltViewModel
22 | import com.ang.acb.movienight.R
23 | import com.ang.acb.movienight.ui.common.LoadingBox
24 | import com.ang.acb.movienight.ui.common.MessageBox
25 | import timber.log.Timber
26 |
27 | @Composable
28 | fun CastDetailsScreen(
29 | viewModel: CastDetailsViewModel = hiltViewModel(),
30 | upPressed: () -> Unit
31 | ) {
32 | val context = LocalContext.current
33 | val scrollState = rememberScrollState()
34 |
35 | Scaffold(
36 | topBar = {
37 | CastDetailsTopBar(upPressed = upPressed)
38 | },
39 | content = { padding ->
40 | if (viewModel.isLoading) {
41 | LoadingBox()
42 | } else {
43 | if (viewModel.errorMessage != null) {
44 | MessageBox(messageResId = viewModel.errorMessage!!)
45 | } else {
46 | viewModel.castDetails?.let { cast ->
47 | Column(
48 | modifier = Modifier
49 | .verticalScroll(scrollState)
50 | .padding(padding)
51 | ) {
52 | CastInfoAvatarRow(
53 | cast = cast,
54 | openImdb = { imdbUrl -> openImdbPage(imdbUrl, context) }
55 | )
56 |
57 | if (cast.biography.isNullOrEmpty().not()) {
58 | MovieInfoHeader(stringResource(R.string.cast_details_biography_label))
59 | Text(
60 | text = cast.biography!!,
61 | textAlign = TextAlign.Justify,
62 | modifier = Modifier.padding(horizontal = 16.dp),
63 | )
64 | Spacer(modifier = Modifier.height(16.dp))
65 | }
66 | }
67 | }
68 | }
69 | }
70 | }
71 | )
72 | }
73 |
74 | private fun openImdbPage(imdbUrl: String, context: Context) {
75 | val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(imdbUrl))
76 | try {
77 | context.startActivity(webIntent)
78 | } catch (e: ActivityNotFoundException) {
79 | Timber.e(e)
80 | }
81 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.theme
2 |
3 | import androidx.compose.material.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.Font
6 | import androidx.compose.ui.text.font.FontFamily
7 | import androidx.compose.ui.text.font.FontWeight
8 | import androidx.compose.ui.unit.sp
9 | import com.ang.acb.movienight.R
10 |
11 | private val light = Font(R.font.poppins_light, FontWeight.W300)
12 | private val regular = Font(R.font.poppins_regular, FontWeight.W400)
13 | private val medium = Font(R.font.poppins_medium, FontWeight.W500)
14 | private val bold = Font(R.font.poppins_bold, FontWeight.W700)
15 |
16 | val moviesFontFamily = FontFamily(fonts = listOf(light, regular, medium, bold))
17 |
18 | // see: https://material.io/design/typography/the-type-system.html#type-scale
19 | val moviesTypography = Typography(
20 | h1 = TextStyle(
21 | fontFamily = moviesFontFamily,
22 | fontWeight = FontWeight.Light,
23 | fontSize = 93.sp,
24 | letterSpacing = (-1.5).sp,
25 | ),
26 | h2 = TextStyle(
27 | fontFamily = moviesFontFamily,
28 | fontWeight = FontWeight.Light,
29 | fontSize = 58.sp,
30 | letterSpacing = (-0.5).sp,
31 | ),
32 | h3 = TextStyle(
33 | fontFamily = moviesFontFamily,
34 | fontWeight = FontWeight.Normal,
35 | fontSize = 46.sp,
36 | letterSpacing = 0.sp,
37 | ),
38 | h4 = TextStyle(
39 | fontFamily = moviesFontFamily,
40 | fontWeight = FontWeight.Normal,
41 | fontSize = 33.sp,
42 | letterSpacing = 0.25.sp,
43 | ),
44 | h5 = TextStyle(
45 | fontFamily = moviesFontFamily,
46 | fontWeight = FontWeight.Normal,
47 | fontSize = 23.sp,
48 | letterSpacing = 0.sp,
49 | ),
50 | h6 = TextStyle(
51 | fontFamily = moviesFontFamily,
52 | fontWeight = FontWeight.Medium,
53 | fontSize = 19.sp,
54 | letterSpacing = 0.15.sp,
55 | ),
56 | subtitle1 = TextStyle(
57 | fontFamily = moviesFontFamily,
58 | fontWeight = FontWeight.Normal,
59 | fontSize = 15.sp,
60 | letterSpacing = 0.15.sp,
61 | ),
62 | subtitle2 = TextStyle(
63 | fontFamily = moviesFontFamily,
64 | fontWeight = FontWeight.Medium,
65 | fontSize = 13.sp,
66 | letterSpacing = 0.1.sp,
67 | ),
68 | body1 = TextStyle(
69 | fontFamily = moviesFontFamily,
70 | fontWeight = FontWeight.Normal,
71 | fontSize = 15.sp,
72 | letterSpacing = 0.5.sp,
73 | ),
74 | body2 = TextStyle(
75 | fontFamily = moviesFontFamily,
76 | fontWeight = FontWeight.Normal,
77 | fontSize = 13.sp,
78 | letterSpacing = 0.25.sp,
79 | ),
80 | button = TextStyle(
81 | fontFamily = moviesFontFamily,
82 | fontWeight = FontWeight.Medium,
83 | fontSize = 13.sp,
84 | letterSpacing = 1.25.sp,
85 | ),
86 | caption = TextStyle(
87 | fontFamily = moviesFontFamily,
88 | fontWeight = FontWeight.Normal,
89 | fontSize = 12.sp,
90 | letterSpacing = 0.4.sp,
91 | ),
92 | overline = TextStyle(
93 | fontFamily = moviesFontFamily,
94 | fontWeight = FontWeight.Normal,
95 | fontSize = 10.sp,
96 | letterSpacing = 1.5.sp
97 | )
98 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/search/SearchMoviesTextField.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.search
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.ExperimentalAnimationApi
5 | import androidx.compose.animation.fadeIn
6 | import androidx.compose.animation.fadeOut
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.text.KeyboardOptions
10 | import androidx.compose.material.*
11 | import androidx.compose.material.icons.Icons
12 | import androidx.compose.material.icons.filled.Clear
13 | import androidx.compose.material.icons.filled.Search
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.res.stringResource
18 | import androidx.compose.ui.text.input.ImeAction
19 | import androidx.compose.ui.text.input.KeyboardType
20 | import androidx.compose.ui.text.input.TextFieldValue
21 | import androidx.compose.ui.unit.dp
22 | import com.ang.acb.movienight.R
23 | import com.ang.acb.movienight.ui.theme.midnight200
24 | import com.ang.acb.movienight.ui.theme.midnight800
25 |
26 | @ExperimentalAnimationApi
27 | @Composable
28 | fun SearchMoviesTextField(
29 | value: TextFieldValue,
30 | onValueChange: (TextFieldValue) -> Unit,
31 | ) {
32 | OutlinedTextField(
33 | modifier = Modifier
34 | .fillMaxWidth()
35 | .padding(16.dp),
36 | leadingIcon = {
37 | Icon(
38 | imageVector = Icons.Default.Search,
39 | contentDescription = null,
40 | tint = midnight200,
41 | )
42 | },
43 | trailingIcon = {
44 | AnimatedVisibility(
45 | visible = value.text.isNotEmpty(),
46 | enter = fadeIn(),
47 | exit = fadeOut()
48 | ) {
49 | IconButton(
50 | onClick = { onValueChange(TextFieldValue()) },
51 | ) {
52 | Icon(
53 | imageVector = Icons.Default.Clear,
54 | contentDescription = stringResource(R.string.clear_txt_icon_cd),
55 | tint = midnight200,
56 | )
57 | }
58 | }
59 | },
60 | keyboardOptions = KeyboardOptions(
61 | imeAction = ImeAction.Search,
62 | keyboardType = KeyboardType.Text,
63 | ),
64 | value = value,
65 | onValueChange = onValueChange,
66 | placeholder = {
67 | Text(text = stringResource(id = R.string.search_movies_hint))
68 | },
69 | maxLines = 1,
70 | singleLine = true,
71 | colors = TextFieldDefaults.textFieldColors(
72 | textColor = midnight800,
73 | disabledTextColor = midnight200,
74 | placeholderColor = midnight200,
75 | disabledPlaceholderColor = midnight200,
76 | backgroundColor = Color.White,
77 | cursorColor = midnight800,
78 | errorCursorColor = MaterialTheme.colors.error,
79 | focusedIndicatorColor = midnight200,
80 | unfocusedIndicatorColor = midnight200,
81 | errorIndicatorColor = MaterialTheme.colors.error,
82 | )
83 | )
84 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/search/SearchMoviesResults.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.search
2 |
3 | import androidx.compose.foundation.lazy.LazyColumn
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.res.stringResource
7 | import androidx.paging.LoadState
8 | import androidx.paging.PagingData
9 | import androidx.paging.compose.collectAsLazyPagingItems
10 | import androidx.paging.compose.items
11 | import com.ang.acb.movienight.R
12 | import com.ang.acb.movienight.domain.entities.Movie
13 | import com.ang.acb.movienight.ui.common.*
14 | import kotlinx.coroutines.flow.Flow
15 |
16 | @Composable
17 | fun SearchMoviesResults(
18 | searchTerm: String,
19 | searchResults: Flow>,
20 | onItemClick: (movieId: Long) -> Unit,
21 | ) {
22 | val lazyPagingItems = searchResults.collectAsLazyPagingItems()
23 |
24 | LazyColumn {
25 | items(
26 | items = lazyPagingItems,
27 | key = { item -> item.id }
28 | ) { item ->
29 | if (item != null) {
30 | MovieItem(
31 | movie = item,
32 | onMovieClick = onItemClick
33 | )
34 | }
35 | }
36 |
37 | lazyPagingItems.apply {
38 | when (loadState.refresh) {
39 | is LoadState.Loading -> {
40 | item {
41 | PagingLoadingView(modifier = Modifier.fillParentMaxSize())
42 | }
43 | }
44 |
45 | is LoadState.Error -> {
46 | val state = lazyPagingItems.loadState.refresh as LoadState.Error
47 | item {
48 | PagingErrorMessage(
49 | modifier = Modifier.fillParentMaxSize(),
50 | message = state.error.localizedMessage
51 | ?: stringResource(R.string.generic_error_message),
52 | onRetryClick = { retry() }
53 | )
54 | }
55 | }
56 |
57 | LoadState.NotLoading(endOfPaginationReached = true) -> {
58 | if (lazyPagingItems.itemCount == 0) {
59 | item {
60 | SearchResultEmptyMessage(searchTerm)
61 | }
62 | }
63 | }
64 | else -> {}
65 | }
66 |
67 | when (loadState.append) {
68 | is LoadState.Loading -> {
69 | item {
70 | PagingLoadingItem()
71 | }
72 | }
73 |
74 | is LoadState.Error -> {
75 | val state = lazyPagingItems.loadState.append as LoadState.Error
76 | item {
77 | PagingErrorItem(
78 | message = state.error.localizedMessage
79 | ?: stringResource(R.string.generic_error_message),
80 | onRetryClick = { retry() }
81 | )
82 | }
83 | }
84 |
85 | LoadState.NotLoading(endOfPaginationReached = true) -> {
86 | if (lazyPagingItems.itemCount == 0) {
87 | item {
88 | SearchResultEmptyMessage(searchTerm)
89 | }
90 | }
91 | }
92 | else -> {}
93 | }
94 | }
95 | }
96 | }
--------------------------------------------------------------------------------
/data/src/test/resources/response/cast_details_alpacino.json:
--------------------------------------------------------------------------------
1 | {
2 | "adult": false,
3 | "also_known_as": [
4 | "Аль Пачино",
5 | "آل باتشينو",
6 | "艾尔·帕西诺",
7 | "อัล ปาชิโน",
8 | "アル・パチーノ",
9 | "알 파치노",
10 | "Αλ Πατσίνο",
11 | "Αλφρέντο Τζέημς Πατσίνο",
12 | "Alfredo James Pacino",
13 | "അൽ പച്ചിനോ"
14 | ],
15 | "biography": "Alfredo James Pacino (born April 25, 1940) is an American actor and filmmaker. In a career spanning over five decades, he has received many awards and nominations, including an Academy Award, two Tony Awards, and two Primetime Emmy Awards. He is one of the few performers to have received the Triple Crown of Acting. He has also been honored with the AFI Life Achievement Award, the Cecil B. DeMille Award, and the National Medal of Arts. A method actor and former student of the HB Studio and the Actors Studio, where he was taught by Charlie Laughton and Lee Strasberg, Pacino's film debut came at the age of 29 with a minor role in Me, Natalie (1969). He gained favorable notice for his first lead role as a heroin addict in The Panic in Needle Park (1971). Wide acclaim and recognition came with his breakthrough role as Michael Corleone in Francis Ford Coppola's The Godfather (1972), for which he received his first Oscar nomination, and he would reprise the role in the sequels The Godfather Part II (1974) and The Godfather Part III (1990). His portrayal of Michael Corleone is regarded as one of the greatest in film history. Pacino received nominations for the Academy Award for Best Actor for Serpico (1973), The Godfather Part II, Dog Day Afternoon (1975), and ...And Justice for All (1979), ultimately winning it for playing a blind military veteran in Scent of a Woman (1992). For his performances in The Godfather, Dick Tracy (1990), Glengarry Glen Ross (1992), and The Irishman (2019), he earned Best Supporting Actor Oscar nominations. Other notable portrayals include Tony Montana in Scarface (1983), Carlito Brigante in Carlito's Way (1993), Benjamin Ruggiero in Donnie Brasco (1997), and Lowell Bergman in The Insider (1999). He has also starred in the thrillers Heat (1995), The Devil's Advocate (1997), Insomnia (2002), and appeared in Once Upon a Time in Hollywood (2019). On television, Pacino has acted in several productions for HBO, including Angels in America (2003) and the Jack Kevorkian biopic You Don't Know Jack (2010), winning a Primetime Emmy Award for Outstanding Lead Actor in a Miniseries or a Movie for each. Pacino currently stars in the Amazon Video web television series Hunters (2020–present). He has also had an extensive career on stage. He is a two-time Tony Award winner, in 1969 and 1977, for his performances in Does a Tiger Wear a Necktie? and The Basic Training of Pavlo Hummel. Pacino made his filmmaking debut with Looking for Richard (1996), directing and starring in this documentary about Richard III; Pacino had played the lead role on stage in 1977. He has also acted as Shylock in a 2004 feature film adaptation and 2010 stage production of The Merchant of Venice. Pacino directed and starred in Chinese Coffee (2000), Wilde Salomé (2011), and Salomé (2013). Since 1994, he has been the joint president of the Actors Studio. Description above from the Wikipedia article Al Pacino, licensed under CC-BY-SA, full list of contributors on Wikipedia.",
16 | "birthday": "1940-04-25",
17 | "deathday": null,
18 | "gender": 2,
19 | "homepage": null,
20 | "id": 1158,
21 | "imdb_id": "nm0000199",
22 | "known_for_department": "Acting",
23 | "name": "Al Pacino",
24 | "place_of_birth": "New York City, New York, USA",
25 | "popularity": 12.653,
26 | "profile_path": "/fMDFeVf0pjopTJbyRSLFwNDm8Wr.jpg"
27 | }
--------------------------------------------------------------------------------
/data/src/main/java/com/ang/acb/movienight/data/MovieRepository.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data
2 |
3 | import com.ang.acb.movienight.data.source.local.FavoriteMovie
4 | import com.ang.acb.movienight.data.source.local.LocalMovieDataSource
5 | import com.ang.acb.movienight.data.source.local.asMovie
6 | import com.ang.acb.movienight.data.source.local.asMovies
7 | import com.ang.acb.movienight.data.source.remote.RemoteMovieDataSource
8 | import com.ang.acb.movienight.domain.entities.CastDetails
9 | import com.ang.acb.movienight.domain.entities.Movie
10 | import com.ang.acb.movienight.domain.entities.MovieDetails
11 | import com.ang.acb.movienight.domain.entities.Movies
12 | import com.ang.acb.movienight.domain.gateways.MovieGateway
13 | import kotlinx.coroutines.flow.Flow
14 | import kotlinx.coroutines.flow.map
15 | import javax.inject.Inject
16 |
17 | class MovieRepository @Inject constructor(
18 | private val remoteMovieDataSource: RemoteMovieDataSource,
19 | private val localMovieDataSource: LocalMovieDataSource,
20 | ) : MovieGateway {
21 |
22 | override suspend fun getPopularMovies(page: Int): Movies {
23 | return remoteMovieDataSource.getPopularMovies(page)
24 | }
25 |
26 | override suspend fun getTopRatedMovies(page: Int): Movies {
27 | return remoteMovieDataSource.getTopRatedMovies(page)
28 | }
29 |
30 | override suspend fun getNowPlayingMovies(page: Int): Movies {
31 | return remoteMovieDataSource.getNowPlayingMovies(page)
32 | }
33 |
34 | override suspend fun getUpcomingMovies(page: Int): Movies {
35 | return remoteMovieDataSource.getUpcomingMovies(page)
36 | }
37 |
38 | override suspend fun searchMovies(query: String, page: Int): Movies {
39 | return remoteMovieDataSource.searchMovies(query, page)
40 | }
41 |
42 | override suspend fun getAllMovieDetails(movieId: Long): MovieDetails {
43 | return remoteMovieDataSource.getAllMovieDetails(movieId)
44 | }
45 |
46 | override suspend fun getSimilarMovies(movieId: Long): Movies {
47 | return remoteMovieDataSource.getSimilarMovies(movieId)
48 | }
49 |
50 | override suspend fun getCastDetails(castId: Long): CastDetails {
51 | return remoteMovieDataSource.getCastDetails(castId)
52 | }
53 |
54 | override suspend fun saveFavoriteMovie(movie: Movie): Long {
55 | return localMovieDataSource.saveFavoriteMovie(
56 | FavoriteMovie(
57 | id = movie.id,
58 | title = movie.title,
59 | overview = movie.overview,
60 | releaseDate = movie.releaseDate,
61 | posterPath = movie.posterPath,
62 | backdropPath = movie.backdropPath,
63 | popularity = movie.popularity,
64 | voteAverage = movie.voteAverage,
65 | voteCount = movie.voteCount,
66 | isFavorite = true
67 | )
68 | )
69 | }
70 |
71 | override suspend fun updateFavoriteFlag(movieId: String, isFavorite: Boolean) {
72 | return localMovieDataSource.updateFavoriteFlag(movieId, isFavorite)
73 | }
74 |
75 | override suspend fun deleteFavoriteMovie(movieId: Long): Int {
76 | return localMovieDataSource.deleteFavoriteMovie(movieId)
77 | }
78 |
79 | override suspend fun deleteAllFavoriteMovies() {
80 | return localMovieDataSource.deleteAllFavoriteMovies()
81 | }
82 |
83 | override fun getFavoriteMovie(movieId: Long): Flow {
84 | return localMovieDataSource.getFavoriteMovie(movieId).map { it?.asMovie() }
85 | }
86 |
87 | override fun getAllFavoriteMovies(): Flow> {
88 | return localMovieDataSource.getAllFavoriteMovies().map { it.asMovies() }
89 | }
90 | }
--------------------------------------------------------------------------------
/data/src/androidTest/java/com/ang/acb/movienight/data/source/local/MovieDaoTest.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import androidx.room.Room
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import androidx.test.filters.SmallTest
7 | import androidx.test.platform.app.InstrumentationRegistry
8 | import kotlinx.coroutines.flow.first
9 | import kotlinx.coroutines.runBlocking
10 | import org.hamcrest.CoreMatchers.`is`
11 | import org.hamcrest.CoreMatchers.notNullValue
12 | import org.hamcrest.MatcherAssert.assertThat
13 | import org.junit.After
14 | import org.junit.Before
15 | import org.junit.Rule
16 | import org.junit.Test
17 | import org.junit.runner.RunWith
18 |
19 | @SmallTest
20 | @RunWith(AndroidJUnit4::class)
21 | class MovieDaoTest {
22 | private lateinit var database: MoviesDatabase
23 | private lateinit var movieDao: MovieDao
24 |
25 | private val movieA = FavoriteMovie(
26 | id = 1L,
27 | title = "A",
28 | overview = "overview A",
29 | releaseDate = "11-11-2020",
30 | posterPath = null,
31 | backdropPath = null,
32 | popularity = 77.7,
33 | voteAverage = 7.7,
34 | voteCount = 77,
35 | isFavorite = true,
36 | )
37 | private val movieB = FavoriteMovie(
38 | id = 2L,
39 | title = "B",
40 | overview = "overview B",
41 | releaseDate = "12-11-2020",
42 | posterPath = null,
43 | backdropPath = null,
44 | popularity = 88.8,
45 | voteAverage = 8.8,
46 | voteCount = 88,
47 | isFavorite = true,
48 | )
49 | private val movieC = FavoriteMovie(
50 | id = 3L,
51 | title = "C",
52 | overview = "overview C",
53 | releaseDate = "10-11-2020",
54 | posterPath = null,
55 | backdropPath = null,
56 | popularity = 99.9,
57 | voteAverage = 9.9,
58 | voteCount = 99,
59 | isFavorite = true,
60 | )
61 |
62 | // Swaps the background executor used by the Architecture Components
63 | // with a different one which executes each task synchronously
64 | @get:Rule
65 | var instantTaskExecutorRule = InstantTaskExecutorRule()
66 |
67 | @Before
68 | fun createDb() {
69 | runBlocking {
70 | // Create an in-memory version of the database
71 | val context = InstrumentationRegistry.getInstrumentation().targetContext
72 | database = Room.inMemoryDatabaseBuilder(context, MoviesDatabase::class.java).build()
73 | movieDao = database.movieDao
74 |
75 | // Insert movies in non-alphabetical order to test that results are sorted by title
76 | movieDao.insertMovie(movieB)
77 | movieDao.insertMovie(movieA)
78 | movieDao.insertMovie(movieC)
79 | }
80 | }
81 |
82 | @After
83 | fun closeDb() {
84 | database.close()
85 | }
86 |
87 | @Test
88 | fun testGetFavoriteMovies() {
89 | runBlocking {
90 | val movies = movieDao.getAllFavoriteMovies().first()
91 | assertThat(movies.size, `is`(3))
92 |
93 | // Ensure list is sorted by title
94 | assertThat(movies[0], `is`(movieA))
95 | assertThat(movies[1], `is`(movieB))
96 | assertThat(movies[2], `is`(movieC))
97 | }
98 | }
99 |
100 | @Test
101 | fun testGetFavoriteMovie() {
102 | runBlocking {
103 | assertThat(movieDao.getMovie(movieA.id).first(), notNullValue())
104 | assertThat(movieDao.getMovie(movieA.id).first(), `is`(movieA))
105 | }
106 | }
107 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/filter/FilterMoviesScreen.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.filter
2 |
3 | import androidx.compose.foundation.lazy.LazyColumn
4 | import androidx.compose.material.Scaffold
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.res.stringResource
8 | import androidx.hilt.navigation.compose.hiltViewModel
9 | import androidx.paging.LoadState
10 | import androidx.paging.compose.collectAsLazyPagingItems
11 | import androidx.paging.compose.items
12 | import com.ang.acb.movienight.R
13 | import com.ang.acb.movienight.ui.common.*
14 | import kotlinx.coroutines.FlowPreview
15 |
16 | @FlowPreview
17 | @Composable
18 | fun FilterMoviesScreen(
19 | viewModel: FilterMoviesViewModel = hiltViewModel(),
20 | openMovieDetails: (movieId: Long) -> Unit
21 | ) {
22 | Scaffold(
23 | topBar = {
24 | FilterMoviesTopBar(
25 | onFilterChanged = { viewModel.filter = it },
26 | filterLabel = viewModel.getFilterLabel()
27 | )
28 | },
29 | content = { padding ->
30 | val lazyPagingItems =
31 | viewModel.getPagedMovies(viewModel.filter).collectAsLazyPagingItems()
32 |
33 | LazyColumn(contentPadding = padding) {
34 | items(
35 | items = lazyPagingItems,
36 | // The key is important so the lazy list can remember your
37 | // scroll position when more items are fetched!
38 | key = { item -> item.id }
39 | ) { item ->
40 | if (item != null) {
41 | MovieItem(
42 | movie = item,
43 | onMovieClick = { openMovieDetails(it) }
44 | )
45 | }
46 | }
47 |
48 | lazyPagingItems.apply {
49 | when {
50 | loadState.refresh is LoadState.Loading -> {
51 | item {
52 | PagingLoadingView(modifier = Modifier.fillParentMaxSize())
53 | }
54 | }
55 |
56 | loadState.append is LoadState.Loading -> {
57 | item {
58 | PagingLoadingItem()
59 | }
60 | }
61 |
62 | loadState.refresh is LoadState.Error -> {
63 | val state = lazyPagingItems.loadState.refresh as LoadState.Error
64 | item {
65 | PagingErrorMessage(
66 | modifier = Modifier.fillParentMaxSize(),
67 | message = state.error.localizedMessage
68 | ?: stringResource(R.string.generic_error_message),
69 | onRetryClick = { retry() }
70 | )
71 | }
72 | }
73 |
74 | loadState.append is LoadState.Error -> {
75 | val state = lazyPagingItems.loadState.append as LoadState.Error
76 | item {
77 | PagingErrorItem(
78 | message = state.error.localizedMessage
79 | ?: stringResource(R.string.generic_error_message),
80 | onRetryClick = { retry() }
81 | )
82 | }
83 | }
84 | }
85 | }
86 | }
87 | }
88 | )
89 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieDetailsViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import androidx.compose.runtime.getValue
4 | import androidx.compose.runtime.mutableStateOf
5 | import androidx.compose.runtime.setValue
6 | import androidx.lifecycle.SavedStateHandle
7 | import androidx.lifecycle.ViewModel
8 | import androidx.lifecycle.viewModelScope
9 | import com.ang.acb.movienight.R
10 | import com.ang.acb.movienight.domain.entities.Movie
11 | import com.ang.acb.movienight.domain.entities.MovieDetails
12 | import com.ang.acb.movienight.domain.usecases.*
13 | import dagger.hilt.android.lifecycle.HiltViewModel
14 | import kotlinx.coroutines.flow.catch
15 | import kotlinx.coroutines.flow.collect
16 | import kotlinx.coroutines.launch
17 | import timber.log.Timber
18 | import javax.inject.Inject
19 |
20 | @HiltViewModel
21 | class MovieDetailsViewModel @Inject constructor(
22 | savedStateHandle: SavedStateHandle,
23 | private val getMovieDetailsUseCase: GetMovieDetailsUseCase,
24 | private val getSimilarMoviesUseCase: GetSimilarMoviesUseCase,
25 | private val saveFavoriteMovieUseCase: SaveFavoriteMovieUseCase,
26 | private val deleteFavoriteMovieUseCase: DeleteFavoriteMovieUseCase,
27 | private val getFavoriteMovieUseCase: GetFavoriteMovieUseCase,
28 | ) : ViewModel() {
29 |
30 | val movieId: Long = savedStateHandle.get("movieId")!!
31 |
32 | var movieDetails: MovieDetails? by mutableStateOf(null)
33 | var similarMovies: List by mutableStateOf(emptyList())
34 | var isFavorite: Boolean? by mutableStateOf(null)
35 | var isFavoriteLoading: Boolean by mutableStateOf(false)
36 | var isLoading: Boolean by mutableStateOf(false)
37 | var errorMessage: Int? by mutableStateOf(null)
38 |
39 | init {
40 | getMovieDetails(movieId)
41 | getSimilarMovies(movieId)
42 | getIsFavorite(movieId)
43 | }
44 |
45 | private fun getMovieDetails(movieId: Long) {
46 | viewModelScope.launch {
47 | isLoading = true
48 | try {
49 | movieDetails = getMovieDetailsUseCase(movieId)
50 | } catch (e: Exception) {
51 | Timber.e(e)
52 | errorMessage = R.string.get_movie_details_error_message
53 | }
54 | isLoading = false
55 | }
56 | }
57 |
58 | private fun getSimilarMovies(movieId: Long) {
59 | viewModelScope.launch {
60 | isLoading = true
61 | try {
62 | similarMovies = getSimilarMoviesUseCase(movieId)
63 | } catch (e: Exception) {
64 | Timber.e(e)
65 | }
66 | isLoading = false
67 | }
68 | }
69 |
70 | private fun getIsFavorite(movieId: Long) {
71 | viewModelScope.launch {
72 | isFavoriteLoading = true
73 | getFavoriteMovieUseCase(movieId)
74 | .catch {
75 | Timber.e(it)
76 | isFavoriteLoading = false
77 | }
78 | .collect {
79 | isFavorite = it?.isFavorite
80 | isFavoriteLoading = false
81 | }
82 | }
83 | }
84 |
85 | fun onFavoriteClicked() {
86 | viewModelScope.launch {
87 | isFavoriteLoading = true
88 | if (isFavorite == true) {
89 | val deleted = deleteFavoriteMovieUseCase(movieId)
90 | isFavorite = deleted.equals(1)
91 | Timber.d("asd deleted from favorites $deleted movie with id = $movieId")
92 | } else {
93 | val savedId = movieDetails?.movie?.let { saveFavoriteMovieUseCase(it) }
94 | isFavorite = savedId?.equals(movieId)
95 | Timber.d("asd saved to favorites $savedId movie with id = $movieId")
96 | }
97 | isFavoriteLoading = false
98 | }
99 | }
100 | }
--------------------------------------------------------------------------------
/data/src/test/java/com/ang/acb/movienight/data/source/remote/MovieServiceTest.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.remote
2 |
3 | import kotlinx.coroutines.ExperimentalCoroutinesApi
4 | import kotlinx.coroutines.runBlocking
5 | import okhttp3.mockwebserver.MockResponse
6 | import okhttp3.mockwebserver.MockWebServer
7 | import okio.buffer
8 | import okio.source
9 | import org.hamcrest.CoreMatchers.`is`
10 | import org.hamcrest.CoreMatchers.containsString
11 | import org.hamcrest.MatcherAssert.assertThat
12 | import org.hamcrest.core.IsNull.notNullValue
13 | import org.junit.After
14 | import org.junit.Before
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.junit.runners.JUnit4
18 | import retrofit2.Retrofit
19 | import retrofit2.converter.gson.GsonConverterFactory
20 |
21 | @RunWith(JUnit4::class)
22 | @ExperimentalCoroutinesApi
23 | class MovieServiceTest {
24 |
25 | // Subject under test
26 | private lateinit var apiService: MovieService
27 |
28 | // A scriptable web server for testing HTTP clients. Callers supply canned
29 | // responses and the server replays them upon request in sequence.
30 | private lateinit var mockServer: MockWebServer
31 |
32 | @Before
33 | fun createService() {
34 | mockServer = MockWebServer()
35 |
36 | apiService = Retrofit.Builder()
37 | .baseUrl(mockServer.url("/"))
38 | .addConverterFactory(GsonConverterFactory.create())
39 | .build()
40 | .create(MovieService::class.java)
41 | }
42 |
43 | @After
44 | fun stopService() {
45 | mockServer.shutdown()
46 | }
47 |
48 | @Test
49 | fun getMovieDetails() {
50 | // See: https://github.com/Kotlin/kotlinx.coroutines/issues/1204
51 | runBlocking {
52 | mockServer.enqueueMockResponse("movie_details_thegodfather.json", 200)
53 | val response = apiService.getAllMovieDetails(238)
54 |
55 | // Verify that the endpoint contains the movie ID
56 | val request = mockServer.takeRequest()
57 | assertThat(request.requestUrl, notNullValue())
58 | assertThat(request.path, containsString("238"))
59 |
60 | // Verify that the response contains the expected movie details
61 | val movie = response.asMovie()
62 | val genres = response.asGenres()
63 | val cast = response.asCast()
64 | val trailers = response.asVideos()
65 |
66 | assertThat(movie, notNullValue())
67 | assertThat(movie.title, `is`("The Godfather"))
68 | assertThat(movie.releaseDate, `is`("1972-03-14"))
69 | assertThat(movie.popularity, `is`(31.9))
70 | assertThat(movie.voteAverage, `is`(8.7))
71 | assertThat(movie.voteCount, `is`(11450))
72 |
73 | assertThat(genres, notNullValue())
74 | assertThat(genres.size, `is`(2))
75 | assertThat(genres[0].name, `is`("Drama"))
76 | assertThat(genres[1].name, `is`("Crime"))
77 |
78 | assertThat(cast, notNullValue())
79 | assertThat(cast.size, `is`(59))
80 |
81 | assertThat(trailers, notNullValue())
82 | assertThat(trailers.size, `is`(2))
83 | }
84 | }
85 |
86 | @Test
87 | fun getCastDetails() {
88 | runBlocking {
89 | mockServer.enqueueMockResponse("cast_details_alpacino.json", 200)
90 | val response = apiService.getCastDetails(1158)
91 |
92 | // Verify that the endpoint contains the cast ID
93 | val request = mockServer.takeRequest()
94 | assertThat(request.requestUrl, notNullValue())
95 | assertThat(request.path, containsString("1158"))
96 |
97 | // Verify that the response contains the expected cast details
98 | val cast = response.asCastDetails()
99 |
100 | assertThat(cast, notNullValue())
101 | assertThat(cast.id, `is`(1158))
102 | assertThat(cast.imdbId, `is`("nm0000199"))
103 | assertThat(cast.name, `is`("Al Pacino"))
104 | assertThat(cast.birthday, `is`("1940-04-25"))
105 | }
106 | }
107 |
108 | private fun MockWebServer.enqueueMockResponse(fileName: String, code: Int) {
109 | val inputStream = javaClass.classLoader?.getResourceAsStream("response/$fileName")
110 | val source = inputStream?.let {
111 | inputStream.source().buffer()
112 | }
113 |
114 | source?.let {
115 | val mockResponse = MockResponse()
116 | .setResponseCode(code)
117 | .setBody(source.readString(Charsets.UTF_8))
118 | enqueue(mockResponse)
119 | }
120 | }
121 | }
--------------------------------------------------------------------------------
/data/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.library")
3 | id("kotlin-android")
4 | id("kotlin-kapt")
5 | }
6 |
7 | android {
8 | namespace = "com.ang.acb.movienight.data"
9 |
10 | compileSdk = Versions.compile_sdk
11 |
12 | defaultConfig {
13 | minSdk = Versions.min_sdk
14 |
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | }
17 |
18 | buildTypes {
19 | release {
20 | isMinifyEnabled = false
21 | proguardFiles(
22 | getDefaultProguardFile("proguard-android-optimize.txt"),
23 | "proguard-rules.pro"
24 | )
25 | }
26 | }
27 |
28 | compileOptions {
29 | sourceCompatibility = JavaVersion.VERSION_17
30 | targetCompatibility = JavaVersion.VERSION_17
31 | }
32 |
33 | kotlinOptions {
34 | jvmTarget = Versions.jvm_target
35 | }
36 |
37 | // Build fails after adding the test coroutines dependency
38 | // https://github.com/Kotlin/kotlinx.coroutines/issues/2023
39 | packagingOptions {
40 | packagingOptions.resources.excludes += setOf(
41 | // Exclude AndroidX version files
42 | "META-INF/*.version",
43 | // Exclude consumer proguard files
44 | "META-INF/proguard/*",
45 | // Exclude other random properties files
46 | "/*.properties",
47 | "META-INF/*.properties"
48 | )
49 | }
50 |
51 | testOptions {
52 | unitTests {
53 | isReturnDefaultValues = true
54 | isIncludeAndroidResources = true
55 | }
56 | }
57 | }
58 |
59 | dependencies {
60 | implementation(project(":domain"))
61 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.kotlin_coroutines}")
62 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.kotlin_coroutines}")
63 | implementation("javax.inject:javax.inject:${Versions.jvm_inject}")
64 |
65 | // Networking
66 | implementation("com.squareup.retrofit2:retrofit:${Versions.retrofit}")
67 | implementation("com.squareup.retrofit2:converter-gson:${Versions.retrofit}")
68 | implementation("com.squareup.retrofit2:converter-scalars:${Versions.retrofit}")
69 | implementation("com.squareup.okhttp3:logging-interceptor:${Versions.okhttp_logging}")
70 |
71 | // Room
72 | implementation("androidx.room:room-runtime:${Versions.room}")
73 | implementation("androidx.room:room-ktx:${Versions.room}")
74 | kapt("androidx.room:room-compiler:${Versions.room}")
75 |
76 | // Utils
77 | implementation("com.jakewharton.timber:timber:${Versions.timber}")
78 |
79 | // Testing
80 | testImplementation("junit:junit:${Versions.testing_junit}")
81 | testImplementation("org.hamcrest:hamcrest-library:${Versions.testing_hamcrest}")
82 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.kotlin_coroutines}")
83 | testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.kotlin_coroutines}")
84 | testImplementation("androidx.test:core:${Versions.testing_androidx_core}")
85 | testImplementation("androidx.test.ext:junit:${Versions.testing_junit_ext}")
86 | testImplementation("androidx.arch.core:core-testing:${Versions.testing_arch_core}")
87 | testImplementation("androidx.room:room-testing:${Versions.room}")
88 | testImplementation("com.squareup.okhttp3:mockwebserver:${Versions.testing_mock_web_server}")
89 | testImplementation("org.mockito:mockito-core:${Versions.testing_mockito}")
90 | testImplementation("com.google.dagger:hilt-android-testing:${Versions.di_hilt}")
91 | kaptTest("com.google.dagger:hilt-compiler:${Versions.di_hilt}")
92 |
93 | kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.di_hilt}")
94 | androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.di_hilt}")
95 | androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.kotlin_coroutines}")
96 | androidTestImplementation("androidx.test:core:${Versions.testing_androidx_core}")
97 | androidTestImplementation("androidx.test:runner:${Versions.testing_runner}")
98 | androidTestImplementation("androidx.test:rules:${Versions.testing_rules}")
99 | androidTestImplementation("androidx.test.ext:junit:${Versions.testing_junit_ext}")
100 | androidTestImplementation("androidx.test.ext:truth:${Versions.testing_truth_ext}")
101 | androidTestImplementation("org.hamcrest:hamcrest-library:${Versions.testing_hamcrest}")
102 | androidTestImplementation("androidx.arch.core:core-testing:${Versions.testing_arch_core}")
103 | androidTestImplementation("androidx.test.espresso:espresso-core:${Versions.testing_espresso}")
104 | }
105 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/main/MoviesNavHost.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.main
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.ExperimentalComposeUiApi
6 | import androidx.navigation.*
7 | import androidx.navigation.compose.NavHost
8 | import androidx.navigation.compose.composable
9 | import com.ang.acb.movienight.ui.details.CastDetailsScreen
10 | import com.ang.acb.movienight.ui.details.MovieDetailsScreen
11 | import com.ang.acb.movienight.ui.favorites.FavoriteMoviesScreen
12 | import com.ang.acb.movienight.ui.filter.FilterMoviesScreen
13 | import com.ang.acb.movienight.ui.search.SearchMoviesScreen
14 | import kotlinx.coroutines.FlowPreview
15 |
16 | @ExperimentalAnimationApi
17 | @ExperimentalComposeUiApi
18 | @FlowPreview
19 | @Composable
20 | fun MoviesNavHost(
21 | navController: NavHostController,
22 | ) {
23 | NavHost(
24 | navController = navController,
25 | startDestination = RootScreen.Discover.route,
26 | ) {
27 | // The "discover" nested graph
28 | navigation(
29 | route = RootScreen.Discover.route,
30 | startDestination = LeafScreen.Discover.createRoute(RootScreen.Discover)
31 | ) {
32 | addDiscover(navController, RootScreen.Discover)
33 | addMovieDetails(navController, RootScreen.Discover)
34 | addCastDetails(navController, RootScreen.Discover)
35 | }
36 |
37 | // The "search" nested graph
38 | navigation(
39 | route = RootScreen.Search.route,
40 | startDestination = LeafScreen.Search.createRoute(RootScreen.Search)
41 | ) {
42 | addSearch(navController, RootScreen.Search)
43 | addMovieDetails(navController, RootScreen.Search)
44 | addCastDetails(navController, RootScreen.Search)
45 | }
46 |
47 | // The "favorites" nested graph
48 | navigation(
49 | route = RootScreen.Favorites.route,
50 | startDestination = LeafScreen.Favorites.createRoute(RootScreen.Favorites)
51 | ) {
52 | addFavorites(navController, RootScreen.Favorites)
53 | addMovieDetails(navController, RootScreen.Favorites)
54 | addCastDetails(navController, RootScreen.Favorites)
55 | }
56 | }
57 | }
58 |
59 | @FlowPreview
60 | private fun NavGraphBuilder.addDiscover(navController: NavController, rootScreen: RootScreen) {
61 | composable(
62 | route = LeafScreen.Discover.createRoute(rootScreen)
63 | ) {
64 | FilterMoviesScreen(
65 | openMovieDetails = { movieId ->
66 | navController.navigate(LeafScreen.MovieDetails.createRoute(rootScreen, movieId))
67 | },
68 | )
69 | }
70 | }
71 |
72 | @FlowPreview
73 | @ExperimentalComposeUiApi
74 | @ExperimentalAnimationApi
75 | private fun NavGraphBuilder.addSearch(navController: NavController, rootScreen: RootScreen) {
76 | composable(
77 | route = LeafScreen.Search.createRoute(rootScreen)
78 | ) {
79 | SearchMoviesScreen(
80 | openMovieDetails = { movieId ->
81 | navController.navigate(LeafScreen.MovieDetails.createRoute(rootScreen, movieId))
82 | },
83 | )
84 | }
85 | }
86 |
87 | private fun NavGraphBuilder.addFavorites(navController: NavController, rootScreen: RootScreen) {
88 | composable(
89 | route = LeafScreen.Favorites.createRoute(rootScreen)
90 | ) {
91 | FavoriteMoviesScreen(
92 | openMovieDetails = { movieId ->
93 | navController.navigate(LeafScreen.MovieDetails.createRoute(rootScreen, movieId))
94 | },
95 | )
96 | }
97 | }
98 |
99 | private fun NavGraphBuilder.addMovieDetails(navController: NavController, rootScreen: RootScreen) {
100 | composable(
101 | route = LeafScreen.MovieDetails.createRoute(rootScreen),
102 | arguments = listOf(navArgument("movieId") { type = NavType.LongType })
103 | ) {
104 | MovieDetailsScreen(
105 | upPressed = {
106 | navController.popBackStack()
107 | },
108 | openCastDetails = { cast ->
109 | navController.navigate(LeafScreen.CastDetails.createRoute(rootScreen, castId = cast.id, movieId = cast.movieId))
110 | },
111 | openSimilarMovieDetails = { movieId ->
112 | navController.navigate(LeafScreen.MovieDetails.createRoute(rootScreen, movieId = movieId))
113 | }
114 | )
115 | }
116 | }
117 |
118 | private fun NavGraphBuilder.addCastDetails(navController: NavController, rootScreen: RootScreen) {
119 | composable(
120 | route = LeafScreen.CastDetails.createRoute(rootScreen),
121 | arguments = listOf(navArgument("castId") { type = NavType.LongType })
122 | ) {
123 | CastDetailsScreen(
124 | upPressed = {
125 | navController.popBackStack()
126 | },
127 | )
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/data/src/androidTest/java/com/ang/acb/movienight/data/source/local/LocalMovieDataSourceTest.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.data.source.local
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import androidx.room.Room
5 | import androidx.test.ext.junit.runners.AndroidJUnit4
6 | import androidx.test.filters.MediumTest
7 | import androidx.test.platform.app.InstrumentationRegistry
8 | import com.ang.acb.movienight.data.utils.MainCoroutineRule
9 | import kotlinx.coroutines.Dispatchers
10 | import kotlinx.coroutines.ExperimentalCoroutinesApi
11 | import kotlinx.coroutines.flow.first
12 | import kotlinx.coroutines.test.runBlockingTest
13 | import org.hamcrest.CoreMatchers.`is`
14 | import org.hamcrest.CoreMatchers.notNullValue
15 | import org.hamcrest.MatcherAssert.assertThat
16 | import org.junit.After
17 | import org.junit.Before
18 | import org.junit.Rule
19 | import org.junit.Test
20 | import org.junit.runner.RunWith
21 |
22 |
23 | @MediumTest
24 | @ExperimentalCoroutinesApi
25 | @RunWith(AndroidJUnit4::class)
26 | class LocalMovieDataSourceTest {
27 |
28 | private lateinit var dataSource: LocalMovieDataSource
29 | private lateinit var database: MoviesDatabase
30 | private lateinit var dao: MovieDao
31 |
32 | // Swaps the background executor used by the Architecture Components
33 | // with a different one which executes each task synchronously
34 | @get: Rule
35 | var instantTaskExecutorRule = InstantTaskExecutorRule()
36 |
37 | // Sets the main coroutines dispatcher to a TestCoroutineDispatcher
38 | @get:Rule
39 | var mainCoroutineRule = MainCoroutineRule()
40 |
41 | @Before
42 | fun initDb() {
43 | // Create an in-memory version of the database
44 | val context = InstrumentationRegistry.getInstrumentation().targetContext
45 | database = Room.inMemoryDatabaseBuilder(context, MoviesDatabase::class.java)
46 | .allowMainThreadQueries()
47 | .build()
48 | dao = database.movieDao
49 |
50 | dataSource = LocalMovieDataSource(
51 | dao,
52 | Dispatchers.Main
53 | )
54 | }
55 |
56 | @After
57 | fun closeDb() = database.close()
58 |
59 | @Test
60 | fun insertMovieAndGetById() {
61 | mainCoroutineRule.runBlockingTest {
62 | // Given a new movie that is saved
63 | val testMovie = FavoriteMovie(
64 | id = 1L,
65 | title = "Test title",
66 | overview = "Test overview",
67 | releaseDate = "11-11-2020",
68 | posterPath = null,
69 | backdropPath = null,
70 | popularity = 77.7,
71 | voteAverage = 7.7,
72 | voteCount = 77,
73 | isFavorite = true,
74 | )
75 | val testId = dataSource.saveFavoriteMovie(testMovie)
76 |
77 | // When the movie is retrieved
78 | val loadedMovie = dataSource.getFavoriteMovie(testId).first()
79 |
80 | // Then the loaded data contains the expected values
81 | assertThat(loadedMovie, notNullValue())
82 | assertThat(loadedMovie, `is`(testMovie))
83 | }
84 | }
85 |
86 | @Test
87 | fun insertContactsAndLoadAll() {
88 | mainCoroutineRule.runBlockingTest {
89 | // Given 3 movies that are saved
90 | val movieA = FavoriteMovie(
91 | id = 1L,
92 | title = "A",
93 | overview = "overview A",
94 | releaseDate = "11-11-2020",
95 | posterPath = null,
96 | backdropPath = null,
97 | popularity = 77.7,
98 | voteAverage = 7.7,
99 | voteCount = 77,
100 | isFavorite = true,
101 | )
102 | val movieB = FavoriteMovie(
103 | id = 2L,
104 | title = "B",
105 | overview = "overview B",
106 | releaseDate = "12-11-2020",
107 | posterPath = null,
108 | backdropPath = null,
109 | popularity = 88.8,
110 | voteAverage = 8.8,
111 | voteCount = 88,
112 | isFavorite = true,
113 | )
114 | val movieC = FavoriteMovie(
115 | id = 3L,
116 | title = "C",
117 | overview = "overview C",
118 | releaseDate = "10-11-2020",
119 | posterPath = null,
120 | backdropPath = null,
121 | popularity = 99.9,
122 | voteAverage = 9.9,
123 | voteCount = 99,
124 | isFavorite = true,
125 | )
126 |
127 | dataSource.saveFavoriteMovie(movieA)
128 | dataSource.saveFavoriteMovie(movieB)
129 | dataSource.saveFavoriteMovie(movieC)
130 |
131 | // When loading all the movies
132 | val loaded = dataSource.getAllFavoriteMovies().first()
133 |
134 | // Then the loaded data contains the expected values
135 | assertThat(loaded, notNullValue())
136 | assertThat(loaded.size, `is`(3))
137 | }
138 | }
139 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("kotlin-android")
4 | id("kotlin-kapt")
5 | id("dagger.hilt.android.plugin")
6 | }
7 |
8 | android {
9 | namespace = "com.ang.acb.movienight"
10 |
11 | compileSdk = Versions.compile_sdk
12 |
13 | defaultConfig {
14 | applicationId = "com.ang.acb.movienight"
15 | minSdk = Versions.min_sdk
16 | targetSdk = Versions.target_sdk
17 | versionCode = 1
18 | versionName = "1.0"
19 |
20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 |
22 | buildConfigField(
23 | type = "String",
24 | name = "TMDB_API_KEY",
25 | value = project.properties["TMDB_API_KEY"] as String
26 | )
27 | }
28 |
29 | buildTypes {
30 | release {
31 | isMinifyEnabled = false
32 | proguardFiles(
33 | getDefaultProguardFile("proguard-android-optimize.txt"),
34 | "proguard-rules.pro"
35 | )
36 | }
37 | }
38 |
39 | compileOptions {
40 | sourceCompatibility = JavaVersion.VERSION_17
41 | targetCompatibility = JavaVersion.VERSION_17
42 | }
43 |
44 | kotlinOptions {
45 | jvmTarget = Versions.jvm_target
46 | }
47 |
48 | buildFeatures {
49 | compose = true
50 | buildConfig = true
51 | }
52 |
53 | composeOptions {
54 | kotlinCompilerExtensionVersion = Versions.compose_compiler
55 | }
56 | }
57 |
58 | kapt {
59 | correctErrorTypes = true
60 | }
61 |
62 | dependencies {
63 | implementation(project(":domain"))
64 | implementation(project(":data"))
65 |
66 | // Kotlin Coroutines
67 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.kotlin_coroutines}")
68 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.kotlin_coroutines}")
69 |
70 | // Hilt
71 | implementation("com.google.dagger:hilt-android:${Versions.di_hilt}")
72 | kapt("com.google.dagger:hilt-compiler:${Versions.di_hilt}")
73 |
74 | // AndroidX
75 | implementation("androidx.core:core-ktx:${Versions.androidx_core}")
76 | implementation("androidx.activity:activity-ktx:${Versions.activity}")
77 | implementation("androidx.activity:activity-compose:${Versions.activity}")
78 | implementation("androidx.annotation:annotation:${Versions.annotations}")
79 | implementation("androidx.appcompat:appcompat:${Versions.appcompat}")
80 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:${Versions.lifecycle}")
81 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.lifecycle_view_model_compose}")
82 | implementation("androidx.navigation:navigation-compose:2.6.0")
83 | implementation("androidx.hilt:hilt-navigation-compose:${Versions.hilt_navigation_compose}")
84 | implementation("androidx.paging:paging-compose:${Versions.paging_compose}")
85 | implementation("io.coil-kt:coil-compose:${Versions.coil_compose}")
86 |
87 | // Compose BOM
88 | implementation(platform("androidx.compose:compose-bom:${Versions.compose_bom_version}"))
89 | implementation("androidx.compose.runtime:runtime")
90 | implementation("androidx.compose.material:material")
91 | implementation("androidx.compose.material:material-icons-core")
92 | implementation("androidx.compose.material:material-icons-extended")
93 | implementation("androidx.compose.ui:ui")
94 | implementation("androidx.compose.ui:ui-tooling-preview")
95 | debugImplementation("androidx.compose.ui:ui-tooling")
96 |
97 | // Google
98 | implementation("com.google.android.material:material:${Versions.material}")
99 | implementation("com.google.accompanist:accompanist-flowlayout:${Versions.accompanist}")
100 | implementation("com.google.accompanist:accompanist-insets:${Versions.accompanist}")
101 | implementation("com.google.accompanist:accompanist-systemuicontroller:${Versions.accompanist}")
102 |
103 | // Room
104 | implementation("androidx.room:room-runtime:${Versions.room}")
105 | implementation("androidx.room:room-ktx:${Versions.room}")
106 | kapt("androidx.room:room-compiler:${Versions.room}")
107 |
108 | // Networking
109 | implementation("com.squareup.retrofit2:retrofit:${Versions.retrofit}")
110 | implementation("com.squareup.retrofit2:converter-gson:${Versions.retrofit}")
111 | implementation("com.squareup.retrofit2:converter-scalars:${Versions.retrofit}")
112 | implementation("com.squareup.okhttp3:logging-interceptor:${Versions.okhttp_logging}")
113 |
114 | // Utils
115 | implementation("com.jakewharton.threetenabp:threetenabp:${Versions.three_ten_bp}")
116 | implementation("com.jakewharton.timber:timber:${Versions.timber}")
117 |
118 | // Testing
119 | testImplementation("junit:junit:${Versions.testing_junit}")
120 | testImplementation("androidx.room:room-testing:${Versions.room}")
121 | testImplementation("com.google.dagger:hilt-android-testing:${Versions.di_hilt}")
122 | kaptTest("com.google.dagger:hilt-compiler:${Versions.di_hilt}")
123 | androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.di_hilt}")
124 | kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.di_hilt}")
125 | androidTestImplementation("androidx.test.ext:junit: ${Versions.testing_junit_ext}")
126 | androidTestImplementation("androidx.test.espresso:espresso-core: ${Versions.testing_espresso}")
127 | androidTestImplementation("androidx.compose.ui:ui-test-junit4")
128 | debugImplementation("androidx.compose.ui:ui-test-manifest")
129 | }
130 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/main/java/com/ang/acb/movienight/ui/details/MovieDetailsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.ang.acb.movienight.ui.details
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.net.Uri
6 | import androidx.compose.foundation.layout.*
7 | import androidx.compose.foundation.rememberScrollState
8 | import androidx.compose.foundation.verticalScroll
9 | import androidx.compose.material.Scaffold
10 | import androidx.compose.material.Text
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.compose.ui.res.stringResource
15 | import androidx.compose.ui.text.style.TextAlign
16 | import androidx.compose.ui.unit.dp
17 | import androidx.hilt.navigation.compose.hiltViewModel
18 | import com.ang.acb.movienight.R
19 | import com.ang.acb.movienight.domain.entities.Cast
20 | import com.ang.acb.movienight.domain.entities.Trailer
21 | import com.ang.acb.movienight.ui.common.LoadingBox
22 | import com.ang.acb.movienight.ui.common.MessageBox
23 |
24 | @Composable
25 | fun MovieDetailsScreen(
26 | viewModel: MovieDetailsViewModel = hiltViewModel(),
27 | openCastDetails: (cast: Cast) -> Unit,
28 | openSimilarMovieDetails: (movieId: Long) -> Unit,
29 | upPressed: () -> Unit,
30 | ) {
31 | val scrollState = rememberScrollState()
32 | val context: Context = LocalContext.current
33 |
34 | Scaffold(
35 | topBar = {
36 | val title = viewModel.movieDetails?.movie?.title
37 | MovieDetailsTopBar(
38 | title = title ?: "",
39 | isFavorite = viewModel.isFavorite == true,
40 | isFavoriteLoading = viewModel.isFavoriteLoading,
41 | onFavoriteClicked = { viewModel.onFavoriteClicked() },
42 | upPressed = upPressed
43 | )
44 | },
45 | content = { padding ->
46 | if (viewModel.isLoading) {
47 | LoadingBox()
48 | } else {
49 | if (viewModel.errorMessage != null) {
50 | MessageBox(messageResId = viewModel.errorMessage!!)
51 | } else {
52 | Column(
53 | Modifier
54 | .verticalScroll(scrollState)
55 | .padding(padding)
56 | ) {
57 | viewModel.movieDetails?.let { movieDetails ->
58 | // Movie backdrop image
59 | if (movieDetails.movie.backdropUrl != null) {
60 | MovieBackdropImage(backdropUrl = movieDetails.movie.backdropUrl!!)
61 | }
62 |
63 | // Movie poster
64 | MovieInfoPosterRow(movieDetails = movieDetails)
65 | MovieInfoRating(movie = movieDetails.movie)
66 |
67 | // Movie overview
68 | if (movieDetails.movie.overview.isNullOrEmpty().not()) {
69 | MovieInfoHeader(title = stringResource(R.string.movie_details_overview_label))
70 | Text(
71 | text = "${movieDetails.movie.overview}",
72 | textAlign = TextAlign.Justify,
73 | modifier = Modifier.padding(horizontal = 16.dp)
74 | )
75 | Spacer(modifier = Modifier.height(16.dp))
76 | }
77 |
78 | // Movie cast
79 | if (movieDetails.cast.isNotEmpty()) {
80 | MovieInfoHeader(title = stringResource(R.string.movie_details_cast_label))
81 | CastCarousel(
82 | cast = movieDetails.cast,
83 | onItemClick = { cast -> openCastDetails(cast) },
84 | modifier = Modifier
85 | .fillMaxWidth()
86 | .height(160.dp),
87 | )
88 | Spacer(modifier = Modifier.height(16.dp))
89 | }
90 |
91 | // Movie trailers
92 | if (movieDetails.trailers.isNotEmpty()) {
93 | MovieInfoHeader(title = stringResource(R.string.movie_details_trailers_label))
94 | TrailerCarousel(
95 | trailers = movieDetails.trailers,
96 | onItemClick = { trailer -> playVideo(trailer, context) },
97 | modifier = Modifier
98 | .fillMaxWidth()
99 | .height(128.dp),
100 | )
101 | Spacer(modifier = Modifier.height(16.dp))
102 | }
103 |
104 | // Similar movies
105 | if (viewModel.similarMovies.isNotEmpty()) {
106 | MovieInfoHeader(title = stringResource(R.string.movie_details_similar_label))
107 | SimilarMoviesCarousel(
108 | movies = viewModel.similarMovies,
109 | onItemClick = { openSimilarMovieDetails(it) },
110 | modifier = Modifier
111 | .fillMaxWidth()
112 | .height(160.dp),
113 | )
114 | Spacer(modifier = Modifier.height(16.dp))
115 | }
116 | }
117 | }
118 | }
119 | }
120 | }
121 | )
122 | }
123 |
124 | private fun playVideo(trailer: Trailer, context: Context) {
125 | if (trailer.key != null) {
126 | val appIntent = Intent(Intent.ACTION_VIEW, Uri.parse(trailer.youTubeAppUrl))
127 | val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse(trailer.youTubeWebUrl))
128 |
129 | if (appIntent.resolveActivity(context.packageManager) != null) {
130 | context.startActivity(appIntent)
131 | } else {
132 | context.startActivity(webIntent)
133 | }
134 | }
135 | }
--------------------------------------------------------------------------------