├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── 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 │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── themes.xml │ │ │ │ └── colors.xml │ │ │ ├── values-ar │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── xml │ │ │ │ ├── backup_rules.xml │ │ │ │ └── data_extraction_rules.xml │ │ │ ├── drawable │ │ │ │ ├── ic_dark_mode.xml │ │ │ │ ├── ic_light_mode.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── mmj │ │ │ │ └── movieapp │ │ │ │ ├── core │ │ │ │ ├── generic │ │ │ │ │ ├── usecase │ │ │ │ │ │ └── BaseUseCase.kt │ │ │ │ │ ├── DataState.kt │ │ │ │ │ ├── dto │ │ │ │ │ │ └── ResponseDto.kt │ │ │ │ │ └── UiText.kt │ │ │ │ ├── app │ │ │ │ │ ├── MovieApp.kt │ │ │ │ │ ├── Constants.kt │ │ │ │ │ └── AppPreferences.kt │ │ │ │ ├── network │ │ │ │ │ ├── MovieApi.kt │ │ │ │ │ ├── ErrorHandler.kt │ │ │ │ │ └── CheckInternet.kt │ │ │ │ └── di │ │ │ │ │ ├── AppModule.kt │ │ │ │ │ └── RetrofitModule.kt │ │ │ │ ├── presentation │ │ │ │ ├── util │ │ │ │ │ ├── UIState.kt │ │ │ │ │ ├── resource │ │ │ │ │ │ ├── route │ │ │ │ │ │ │ ├── AppScreen.kt │ │ │ │ │ │ │ └── NavGraph.kt │ │ │ │ │ │ └── theme │ │ │ │ │ │ │ ├── Color.kt │ │ │ │ │ │ │ ├── Type.kt │ │ │ │ │ │ │ └── Theme.kt │ │ │ │ │ └── ItemLazyLoad.kt │ │ │ │ ├── details │ │ │ │ │ ├── DetailsViewModel.kt │ │ │ │ │ └── DetailsScreen.kt │ │ │ │ ├── main │ │ │ │ │ ├── MainViewModel.kt │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── home │ │ │ │ │ ├── HomeViewModel.kt │ │ │ │ │ ├── component │ │ │ │ │ └── ItemMovie.kt │ │ │ │ │ └── HomeScreen.kt │ │ │ │ ├── domain │ │ │ │ ├── repository │ │ │ │ │ └── MovieRepository.kt │ │ │ │ ├── model │ │ │ │ │ └── Movie.kt │ │ │ │ └── usecase │ │ │ │ │ └── GetMoviesUseCase.kt │ │ │ │ └── data │ │ │ │ ├── datasource │ │ │ │ └── remote │ │ │ │ │ ├── MovieRemoteDataSource.kt │ │ │ │ │ └── MovieRemoteDataSourceImpl.kt │ │ │ │ ├── repository │ │ │ │ ├── MovieRepositoryImpl.kt │ │ │ │ └── paging │ │ │ │ │ └── MoviePagingSource.kt │ │ │ │ └── model │ │ │ │ └── remote │ │ │ │ ├── dto │ │ │ │ └── response │ │ │ │ │ └── MovieResponseDto.kt │ │ │ │ └── mapper │ │ │ │ └── MovieResponseDtoMapper.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── mmj │ │ │ └── movieapp │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── mmj │ │ └── movieapp │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── local.properties ├── settings.gradle ├── README.md ├── gradle.properties ├── gradlew.bat └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mohammadjoumani/paging_movie_app_jetpack_compose/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/mohammadjoumani/paging_movie_app_jetpack_compose/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/mohammadjoumani/paging_movie_app_jetpack_compose/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/mohammadjoumani/paging_movie_app_jetpack_compose/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/mohammadjoumani/paging_movie_app_jetpack_compose/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/generic/usecase/BaseUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.generic.usecase 2 | 3 | interface BaseUseCase{ 4 | suspend fun execute(input: In): Out 5 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/UIState.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util 2 | 3 | enum class UIState { 4 | Init, 5 | Loading, 6 | Success, 7 | Error, 8 | Empty 9 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/generic/DataState.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.generic 2 | 3 | sealed class DataState { 4 | data class Success(val data: T) : DataState() 5 | data class Failure(val message: String? = null) : DataState() 6 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sun Apr 30 19:05:15 EET 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Movie App 3 | 4 | Retry 5 | Fetching data from server 6 | Original Language 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/details/DetailsViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.details 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.hilt.android.lifecycle.HiltViewModel 5 | import javax.inject.Inject 6 | 7 | @HiltViewModel 8 | class DetailsViewModel @Inject constructor() : ViewModel() { 9 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/domain/repository/MovieRepository.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.domain.repository 2 | 3 | import androidx.paging.PagingData 4 | import com.mmj.movieapp.domain.model.Movie 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | interface MovieRepository { 8 | 9 | suspend fun getMovies(): Flow> 10 | } -------------------------------------------------------------------------------- /app/src/main/res/values-ar/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Movie App 4 | 5 | Retry 6 | Fetching data from server 7 | Original Language 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/app/MovieApp.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.app 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | import io.paperdb.Paper 6 | 7 | @HiltAndroidApp 8 | class MovieApp : Application() { 9 | 10 | override fun onCreate() { 11 | super.onCreate() 12 | Paper.init(this) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /local.properties: -------------------------------------------------------------------------------- 1 | ## This file must *NOT* be checked into Version Control Systems, 2 | # as it contains information specific to your local configuration. 3 | # 4 | # Location of the SDK. This is only used by Gradle. 5 | # For customization when using a Version Control System, please read the 6 | # header note. 7 | #Wed Jun 07 13:17:53 EET 2023 8 | sdk.dir=C\:\\Users\\MohAmmad\\AppData\\Local\\Android\\Sdk 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/test/java/com/mmj/movieapp/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp 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/mmj/movieapp/data/datasource/remote/MovieRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.data.datasource.remote 2 | 3 | import com.mmj.movieapp.core.generic.dto.ResponseDto 4 | import com.mmj.movieapp.data.model.remote.dto.response.MovieResponseDto 5 | 6 | interface MovieRemoteDataSource { 7 | 8 | suspend fun getMovies( 9 | apiKey: String, 10 | pageNumber: Int 11 | ): ResponseDto> 12 | 13 | } 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/resource/route/AppScreen.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util.resource.route 2 | 3 | sealed class AppScreen(val route: String) { 4 | object HomeScreen : AppScreen(ConstantAppScreenName.HOME_SCREEN) 5 | object DetailsScreen : AppScreen(ConstantAppScreenName.DETAILS_SCREEN) 6 | } 7 | 8 | 9 | object ConstantAppScreenName { 10 | const val HOME_SCREEN = "home_screen" 11 | const val DETAILS_SCREEN = "details_screen" 12 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | maven { url 'https://jitpack.io' } 6 | gradlePluginPortal() 7 | } 8 | } 9 | dependencyResolutionManagement { 10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 11 | repositories { 12 | google() 13 | mavenCentral() 14 | maven { url 'https://jitpack.io' } 15 | gradlePluginPortal() 16 | } 17 | } 18 | rootProject.name = "Movie App" 19 | include ':app' 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/domain/model/Movie.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.domain.model 2 | 3 | data class Movie( 4 | val id: Int, 5 | val title: String, 6 | val originalTitle: String, 7 | val originalLanguage: String, 8 | val adult: Boolean, 9 | val backdropPath: String, 10 | val genreIds: List, 11 | val overview: String, 12 | val popularity: Double, 13 | val posterPath: String, 14 | val releaseDate: String, 15 | val video: Boolean, 16 | val voteAverage: Double, 17 | val voteCount: Int 18 | ) -------------------------------------------------------------------------------- /app/src/main/res/xml/backup_rules.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/app/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.app 2 | 3 | object Constants { 4 | 5 | const val MOVIE_API_KEY = "8f3845576311ddddc2ae7f7801641fdb" 6 | const val IMAGE_URL_MOVIE = "https://image.tmdb.org/t/p/w500" 7 | 8 | // status code 9 | const val SUCCESS = 200 10 | const val FAILURE = 400 11 | 12 | //enum language 13 | const val ENGLISH_LOCALE_LANG = "en" 14 | const val ARABIC_LOCALE_LANG = "ar" 15 | const val DEFAULT_SYSTEM_LOCALE_LANG = "def" 16 | 17 | const val MAX_PAGE_SIZE = 10 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/generic/dto/ResponseDto.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.generic.dto 2 | 3 | import com.google.gson.annotations.SerializedName 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | class ResponseDto { 8 | @SerializedName("results") 9 | val results: T? = null 10 | 11 | @SerializedName("page") 12 | val page: Int? = null 13 | 14 | @SerializedName("total_pages") 15 | val totalPages: Int? = null 16 | 17 | @SerializedName("total_results") 18 | val totalResults: Int? = null 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/resource/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util.resource.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val colorPrimary = Color(0xFFB02E2E) 6 | val colorSecondary = Color(0xFFB02E2E) 7 | 8 | val colorBlack = Color(0xFF0C0C0C) 9 | val colorBlack100 = Color(0xFF17202A) 10 | 11 | val colorWhite = Color(0xFFFFFFFF) 12 | val colorWhite100 = Color(0xFFDAB2B2) 13 | 14 | val colorGrayDark = Color(0xFF0E141C) 15 | val colorGrayLight = Color(0xFFE7E7E7) 16 | 17 | 18 | val colorRed = Color(0xFFFF0000) -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/details/DetailsScreen.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.details 2 | 3 | import androidx.compose.material3.MaterialTheme 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.hilt.navigation.compose.hiltViewModel 7 | import com.mmj.movieapp.presentation.main.MainViewModel 8 | 9 | @Composable 10 | fun DetailsScreen( 11 | mainViewModel: MainViewModel, 12 | viewModel: DetailsViewModel = hiltViewModel() 13 | ) { 14 | 15 | Text(text = "DetailsScreen", color = MaterialTheme.colorScheme.onBackground) 16 | } -------------------------------------------------------------------------------- /app/src/main/res/xml/data_extraction_rules.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/domain/usecase/GetMoviesUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.domain.usecase 2 | 3 | import androidx.paging.PagingData 4 | import com.mmj.movieapp.core.generic.usecase.BaseUseCase 5 | import com.mmj.movieapp.domain.model.Movie 6 | import com.mmj.movieapp.domain.repository.MovieRepository 7 | import kotlinx.coroutines.flow.Flow 8 | import javax.inject.Inject 9 | 10 | class GetMoviesUseCase @Inject constructor( 11 | private val repository: MovieRepository 12 | ) : BaseUseCase>> { 13 | override suspend fun execute(input: Unit): Flow> { 14 | return repository.getMovies() 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_dark_mode.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/network/MovieApi.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.network 2 | 3 | import com.mmj.movieapp.core.generic.dto.ResponseDto 4 | import com.mmj.movieapp.data.model.remote.dto.response.MovieResponseDto 5 | import retrofit2.http.GET 6 | import retrofit2.http.Query 7 | 8 | interface MovieApi { 9 | 10 | companion object { 11 | const val SERVER_URL = "https://api.themoviedb.org/3" 12 | const val API_URL = "$SERVER_URL/movie/" 13 | } 14 | 15 | @GET("popular") 16 | suspend fun getMovies( 17 | @Query("api_key") apiKey: String, 18 | @Query("page") pageNumber: Int 19 | ): ResponseDto> 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/data/datasource/remote/MovieRemoteDataSourceImpl.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.data.datasource.remote 2 | 3 | import com.mmj.movieapp.core.generic.dto.ResponseDto 4 | import com.mmj.movieapp.core.network.MovieApi 5 | import com.mmj.movieapp.data.model.remote.dto.response.MovieResponseDto 6 | import javax.inject.Inject 7 | 8 | class MovieRemoteDataSourceImpl @Inject constructor( 9 | private val api: MovieApi 10 | ) : MovieRemoteDataSource { 11 | 12 | override suspend fun getMovies( 13 | apiKey: String, 14 | pageNumber: Int 15 | ): ResponseDto> { 16 | return api.getMovies(apiKey = apiKey, pageNumber = pageNumber) 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/mmj/movieapp/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp 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.mmj.basestructure", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/network/ErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.network 2 | 3 | import com.mmj.movieapp.R 4 | import com.mmj.movieapp.core.generic.UiText 5 | 6 | fun handleError(message: String): UiText { 7 | return when (message) { 8 | ConstantsErrorHandler.EXCEPTION_MESSAGE -> { 9 | UiText.StringResource(resId = R.string.app_name) 10 | } 11 | ConstantsErrorHandler.NO_CONNECTION_INTERNET_MESSAGE -> { 12 | UiText.StringResource(resId = R.string.app_name) 13 | } 14 | else -> { 15 | UiText.StringResource(resId = R.string.app_name) 16 | } 17 | } 18 | } 19 | 20 | object ConstantsErrorHandler { 21 | // Message Exception 22 | const val EXCEPTION_MESSAGE = "ExceptionMessage" 23 | const val NO_CONNECTION_INTERNET_MESSAGE = "NoConnectionInternetMessage" 24 | 25 | //endregion 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/generic/UiText.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.generic 2 | 3 | import android.content.Context 4 | import androidx.annotation.StringRes 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.res.stringResource 7 | 8 | sealed class UiText { 9 | data class DynamicString(val value: String) : UiText() 10 | class StringResource( 11 | @StringRes val resId: Int, 12 | vararg val args: Any 13 | ) : UiText() 14 | 15 | @Composable 16 | fun asString(): String { 17 | return when (this) { 18 | is DynamicString -> value 19 | is StringResource -> stringResource(resId, *args) 20 | } 21 | } 22 | 23 | fun asString(context: Context): String { 24 | return when (this) { 25 | is DynamicString -> value 26 | is StringResource -> context.getString(resId, *args) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/data/repository/MovieRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.data.repository 2 | 3 | import androidx.paging.Pager 4 | import androidx.paging.PagingConfig 5 | import androidx.paging.PagingData 6 | import com.mmj.movieapp.core.app.Constants 7 | import com.mmj.movieapp.data.datasource.remote.MovieRemoteDataSource 8 | import com.mmj.movieapp.data.repository.paging.MoviePagingSource 9 | import com.mmj.movieapp.domain.model.Movie 10 | import com.mmj.movieapp.domain.repository.MovieRepository 11 | import kotlinx.coroutines.flow.Flow 12 | import javax.inject.Inject 13 | 14 | class MovieRepositoryImpl @Inject constructor( 15 | private val remoteDataSource: MovieRemoteDataSource 16 | ) : MovieRepository { 17 | 18 | override suspend fun getMovies(): Flow> { 19 | return Pager( 20 | config = PagingConfig(pageSize = Constants.MAX_PAGE_SIZE, prefetchDistance = 2), 21 | pagingSourceFactory = { 22 | MoviePagingSource(remoteDataSource) 23 | } 24 | ).flow 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.main 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 com.mmj.movieapp.core.app.AppPreferences 8 | import com.mmj.movieapp.presentation.util.resource.theme.AppTheme 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class MainViewModel @Inject constructor() : ViewModel() { 14 | 15 | var stateApp by mutableStateOf(MainState()) 16 | 17 | fun onEvent(event: MainEvent) { 18 | when(event) { 19 | is MainEvent.ThemeChange -> { 20 | stateApp = stateApp.copy(theme = event.theme) 21 | } 22 | } 23 | } 24 | 25 | } 26 | 27 | sealed class MainEvent { 28 | data class ThemeChange(val theme: AppTheme): MainEvent() 29 | } 30 | 31 | data class MainState( 32 | val theme: AppTheme = AppPreferences.getTheme(), 33 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/resource/route/NavGraph.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util.resource.route 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.navigation.compose.NavHost 5 | import androidx.navigation.compose.composable 6 | import androidx.navigation.compose.rememberNavController 7 | import com.mmj.movieapp.presentation.details.DetailsScreen 8 | import com.mmj.movieapp.presentation.home.HomeScreen 9 | import com.mmj.movieapp.presentation.main.MainViewModel 10 | 11 | @Composable 12 | fun NavGraph(mainViewModel: MainViewModel) { 13 | val navController = rememberNavController() 14 | NavHost( 15 | navController = navController, 16 | startDestination = AppScreen.HomeScreen.route, 17 | ) { 18 | 19 | composable(route = AppScreen.HomeScreen.route) { 20 | HomeScreen( 21 | mainViewModel = mainViewModel, 22 | navController = navController 23 | ) 24 | } 25 | 26 | composable(route = AppScreen.DetailsScreen.route) { 27 | DetailsScreen(mainViewModel) 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/resource/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util.resource.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/data/model/remote/dto/response/MovieResponseDto.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.data.model.remote.dto.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class MovieResponseDto( 6 | 7 | @SerializedName("id") 8 | val id: Int, 9 | 10 | @SerializedName("adult") 11 | val adult: Boolean, 12 | 13 | @SerializedName("backdrop_path") 14 | val backdropPath: String?, 15 | 16 | @SerializedName("genre_ids") 17 | val genreIds: List, 18 | 19 | @SerializedName("original_language") 20 | val originalLanguage: String?, 21 | 22 | @SerializedName("original_title") 23 | val originalTitle: String?, 24 | 25 | @SerializedName("overview") 26 | val overview: String?, 27 | 28 | @SerializedName("popularity") 29 | val popularity: Double, 30 | 31 | @SerializedName("poster_path") 32 | val posterPath: String?, 33 | 34 | @SerializedName("release_date") 35 | val releaseDate: String?, 36 | 37 | @SerializedName("title") 38 | val title: String?, 39 | 40 | @SerializedName("video") 41 | val video: Boolean, 42 | 43 | @SerializedName("vote_average") 44 | val voteAverage: Double, 45 | 46 | @SerializedName("vote_count") 47 | val voteCount: Int 48 | ) -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 18 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paging MovieApp Jetpack Compose 2 | 3 | Welcome to the Pagnation in Jetpack Compose with Clean Architecture GitHub repository! 4 | 5 | #### Image: 6 |
7 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | #### Video: 16 | 17 | 18 | https://github.com/mohammadjoumani/paging_movie_app_jetpack_compose/assets/53276286/801d2f82-5717-4745-8735-a8e288c436dc 19 | 20 | 21 | 22 | [Click here to read medium artical](https://medium.com/@mohammadjoumani/paging-with-clean-architecture-in-jetpack-compose-775fbf589256) 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.main 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.compose.foundation.layout.fillMaxSize 7 | import androidx.compose.material3.MaterialTheme 8 | import androidx.compose.material3.Surface 9 | import androidx.compose.ui.Modifier 10 | import com.mmj.movieapp.presentation.util.resource.route.NavGraph 11 | import com.mmj.movieapp.presentation.util.resource.theme.AppTheme 12 | import com.mmj.movieapp.presentation.util.resource.theme.MovieTheme 13 | import dagger.hilt.android.AndroidEntryPoint 14 | 15 | @AndroidEntryPoint 16 | class MainActivity : ComponentActivity() { 17 | 18 | val viewModel: MainViewModel = MainViewModel() 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | setContent { 23 | MovieTheme(appTheme = viewModel.stateApp.theme) { 24 | Surface( 25 | modifier = Modifier.fillMaxSize(), 26 | color = MaterialTheme.colorScheme.background 27 | ) { 28 | NavGraph(viewModel) 29 | } 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.di 2 | 3 | import com.mmj.movieapp.core.network.MovieApi 4 | import com.mmj.movieapp.data.datasource.remote.MovieRemoteDataSource 5 | import com.mmj.movieapp.data.datasource.remote.MovieRemoteDataSourceImpl 6 | import com.mmj.movieapp.data.repository.MovieRepositoryImpl 7 | import com.mmj.movieapp.domain.repository.MovieRepository 8 | import com.mmj.movieapp.domain.usecase.GetMoviesUseCase 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.components.SingletonComponent 13 | import javax.inject.Singleton 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object AppModule { 18 | 19 | @Singleton 20 | @Provides 21 | fun providesMovieRemoteDataSource( 22 | api: MovieApi 23 | ): MovieRemoteDataSource { 24 | return MovieRemoteDataSourceImpl(api) 25 | } 26 | 27 | @Singleton 28 | @Provides 29 | fun providesMovieRepository( 30 | movieRemoteDataSource: MovieRemoteDataSource 31 | ): MovieRepository { 32 | return MovieRepositoryImpl(movieRemoteDataSource) 33 | } 34 | 35 | @Singleton 36 | @Provides 37 | fun providesGetMoviesUseCase( 38 | movieRepository: MovieRepository 39 | ): GetMoviesUseCase { 40 | return GetMoviesUseCase(movieRepository) 41 | } 42 | } -------------------------------------------------------------------------------- /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. 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 22 | # Please add these rules to your existing keep rules in order to suppress warnings. 23 | # This is generated automatically by the Android Gradle plugin. 24 | #-dontwarn org.bouncycastle.jsse.BCSSLParameters 25 | #-dontwarn org.bouncycastle.jsse.BCSSLSocket 26 | #-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 27 | #-dontwarn org.conscrypt.Conscrypt$Version 28 | #-dontwarn org.conscrypt.Conscrypt 29 | #-dontwarn org.openjsse.javax.net.ssl.SSLParameters 30 | #-dontwarn org.openjsse.javax.net.ssl.SSLSocket 31 | #-dontwarn org.openjsse.net.ssl.OpenJSSE 32 | 33 | -keep public class com.mmj.movieapp.core.generic.dto.** { *; } 34 | -keep public class com.mmj.movieapp.data.model.remote.dto.** { *; } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/app/AppPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.app 2 | 3 | import com.mmj.movieapp.presentation.util.resource.theme.AppTheme 4 | import io.paperdb.Paper 5 | import java.util.* 6 | 7 | object AppPreferences { 8 | 9 | //region LanguageConfig 10 | //key 11 | const val APP_LANG = "AppLang" 12 | 13 | fun setLocale(lang: String) { 14 | Paper.book().write(APP_LANG, lang) 15 | } 16 | 17 | fun getLocale(): String { 18 | return when(Paper.book().read(APP_LANG, Constants.DEFAULT_SYSTEM_LOCALE_LANG)!!) { 19 | Constants.DEFAULT_SYSTEM_LOCALE_LANG -> { 20 | Locale.getDefault().language 21 | } 22 | Constants.ARABIC_LOCALE_LANG -> { 23 | Constants.ARABIC_LOCALE_LANG 24 | } 25 | Constants.ENGLISH_LOCALE_LANG -> { 26 | Constants.ENGLISH_LOCALE_LANG 27 | } 28 | else -> { 29 | Constants.ENGLISH_LOCALE_LANG 30 | } 31 | } 32 | } 33 | 34 | fun getSelectedLanguage(): String { 35 | return Paper.book().read(APP_LANG, Constants.DEFAULT_SYSTEM_LOCALE_LANG)!! 36 | } 37 | 38 | //endregion 39 | 40 | //region Theme 41 | 42 | const val APP_THEME = "AppTheme" 43 | 44 | fun setTheme(theme: AppTheme) { 45 | Paper.book().write(APP_THEME, theme) 46 | } 47 | 48 | fun getTheme(): AppTheme { 49 | return Paper.book().read(APP_THEME, AppTheme.Default)!! 50 | } 51 | 52 | //endregion 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/core/network/CheckInternet.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.network 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.launch 6 | import kotlinx.coroutines.withContext 7 | import java.io.IOException 8 | import java.net.InetSocketAddress 9 | import java.net.Socket 10 | import javax.inject.Inject 11 | 12 | class CheckInternet @Inject constructor() { 13 | companion object { 14 | private const val HOST_NAME = "8.8.8.8" 15 | private const val PORT = 53 16 | private const val TIMEOUT = 1_500 17 | } 18 | 19 | fun isCheck(listener: (connected: Boolean) -> Unit) { 20 | CoroutineScope(Dispatchers.IO).launch(Dispatchers.IO) { 21 | try { 22 | Socket().use { it.connect(InetSocketAddress(HOST_NAME, PORT), TIMEOUT) } 23 | withContext(Dispatchers.Main) { 24 | listener(true) 25 | } 26 | } catch (e: IOException) { 27 | withContext(Dispatchers.Main) { 28 | listener(false) 29 | } 30 | } 31 | } 32 | } 33 | 34 | suspend fun isCheck(): Boolean { 35 | return withContext(Dispatchers.IO) { 36 | try { 37 | Socket().use { it.connect(InetSocketAddress(HOST_NAME, PORT), TIMEOUT) } 38 | return@withContext true 39 | } catch (e: IOException) { 40 | return@withContext false 41 | } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true 24 | MYAPP_UPLOAD_STORE_FILE=movie-key-store.jks 25 | MYAPP_UPLOAD_KEY_ALIAS=movie-key 26 | MYAPP_UPLOAD_STORE_PASSWORD=12345678 27 | MYAPP_UPLOAD_KEY_PASSWORD=12345678 -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/data/repository/paging/MoviePagingSource.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.data.repository.paging 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.paging.PagingState 5 | import com.mmj.movieapp.core.app.Constants 6 | import com.mmj.movieapp.data.datasource.remote.MovieRemoteDataSource 7 | import com.mmj.movieapp.data.model.remote.mapper.mapFromListModel 8 | import com.mmj.movieapp.domain.model.Movie 9 | import retrofit2.HttpException 10 | import java.io.IOException 11 | 12 | class MoviePagingSource( 13 | private val remoteDataSource: MovieRemoteDataSource, 14 | ) : PagingSource() { 15 | 16 | override suspend fun load(params: LoadParams): LoadResult { 17 | return try { 18 | val currentPage = params.key ?: 1 19 | val movies = remoteDataSource.getMovies( 20 | apiKey = Constants.MOVIE_API_KEY, 21 | pageNumber = currentPage 22 | ) 23 | LoadResult.Page( 24 | data = movies.results!!.mapFromListModel(), 25 | prevKey = if (currentPage == 1) null else currentPage - 1, 26 | nextKey = if (movies.results.isEmpty()) null else movies.page!! + 1 27 | ) 28 | } catch (exception: IOException) { 29 | return LoadResult.Error(exception) 30 | } catch (exception: HttpException) { 31 | return LoadResult.Error(exception) 32 | } 33 | } 34 | 35 | override fun getRefreshKey(state: PagingState): Int? { 36 | return state.anchorPosition 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.home 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import androidx.paging.PagingData 6 | import androidx.paging.cachedIn 7 | import com.mmj.movieapp.domain.model.Movie 8 | import com.mmj.movieapp.domain.usecase.GetMoviesUseCase 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.distinctUntilChanged 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class HomeViewModel @Inject constructor( 17 | private val getMoviesUseCase: GetMoviesUseCase 18 | ) : ViewModel() { 19 | 20 | private val _moviesState: MutableStateFlow> = MutableStateFlow(value = PagingData.empty()) 21 | val moviesState: MutableStateFlow> get() = _moviesState 22 | 23 | init { 24 | onEvent(HomeEvent.GetHome) 25 | } 26 | 27 | fun onEvent(event: HomeEvent) { 28 | viewModelScope.launch { 29 | when (event) { 30 | is HomeEvent.GetHome -> { 31 | getMovies() 32 | } 33 | } 34 | } 35 | } 36 | 37 | private suspend fun getMovies() { 38 | getMoviesUseCase.execute(Unit) 39 | .distinctUntilChanged() 40 | .cachedIn(viewModelScope) 41 | .collect { 42 | _moviesState.value = it 43 | } 44 | } 45 | } 46 | 47 | sealed class HomeEvent { 48 | object GetHome : HomeEvent() 49 | } 50 | 51 | data class HomeState( 52 | val movies: List = listOf() 53 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/data/model/remote/mapper/MovieResponseDtoMapper.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.data.model.remote.mapper 2 | 3 | import com.mmj.movieapp.data.model.remote.dto.response.MovieResponseDto 4 | import com.mmj.movieapp.domain.model.Movie 5 | 6 | fun MovieResponseDto.mapFromEntity() = Movie( 7 | id = this.id, 8 | adult = this.adult, 9 | backdropPath = this.backdropPath.orEmpty(), 10 | genreIds = this.genreIds, 11 | originalLanguage = this.originalLanguage.orEmpty(), 12 | originalTitle = this.originalTitle.orEmpty(), 13 | overview = this.overview.orEmpty(), 14 | popularity = this.popularity, 15 | posterPath = this.posterPath.orEmpty(), 16 | releaseDate = this.releaseDate.orEmpty(), 17 | title = this.title.orEmpty(), 18 | video = this.video, 19 | voteAverage = this.voteAverage, 20 | voteCount = this.voteCount 21 | ) 22 | 23 | fun Movie.mapFromDomain() = MovieResponseDto( 24 | id = this.id, 25 | adult = this.adult, 26 | backdropPath = this.backdropPath, 27 | genreIds = this.genreIds, 28 | originalLanguage = this.originalLanguage, 29 | originalTitle = this.originalTitle, 30 | overview = this.overview, 31 | popularity = this.popularity, 32 | posterPath = this.posterPath, 33 | releaseDate = this.releaseDate, 34 | title = this.title, 35 | video = this.video, 36 | voteAverage = this.voteAverage, 37 | voteCount = this.voteCount 38 | ) 39 | 40 | fun List.mapFromListModel(): List { 41 | return this.map { 42 | it.mapFromEntity() 43 | } 44 | } 45 | 46 | fun List.mapFromListDomain(): List { 47 | return this.map { 48 | it.mapFromDomain() 49 | } 50 | } -------------------------------------------------------------------------------- /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/mmj/movieapp/core/di/RetrofitModule.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.core.di 2 | 3 | import com.mmj.movieapp.core.app.AppPreferences 4 | import com.mmj.movieapp.core.app.Constants 5 | import com.mmj.movieapp.core.network.MovieApi 6 | import com.squareup.moshi.Moshi 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.components.SingletonComponent 11 | import okhttp3.Interceptor 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.gson.GsonConverterFactory 16 | import retrofit2.converter.moshi.MoshiConverterFactory 17 | import java.util.concurrent.TimeUnit 18 | import javax.inject.Singleton 19 | 20 | @Module 21 | @InstallIn(SingletonComponent::class) 22 | object RetrofitModule { 23 | 24 | @Singleton 25 | @Provides 26 | fun providesMoshi(): Moshi = Moshi.Builder().build() 27 | 28 | @Singleton 29 | @Provides 30 | fun providesOkHttpClient(): OkHttpClient { 31 | val httpLoggingInterceptor = HttpLoggingInterceptor() 32 | httpLoggingInterceptor.level = HttpLoggingInterceptor.Level.BODY 33 | 34 | return OkHttpClient.Builder() 35 | .addInterceptor(httpLoggingInterceptor) 36 | .addInterceptor( 37 | Interceptor { chain -> 38 | val builder = chain.request().newBuilder() 39 | val language = AppPreferences.getLocale() 40 | builder.header("Accept-Language", language) 41 | return@Interceptor chain.proceed(builder.build()) 42 | } 43 | ) 44 | .readTimeout(60, TimeUnit.SECONDS) 45 | .connectTimeout(60, TimeUnit.SECONDS) 46 | .writeTimeout(60, TimeUnit.SECONDS) 47 | .build() 48 | } 49 | 50 | @Singleton 51 | @Provides 52 | fun providesRetrofit(okHttpClient: OkHttpClient): Retrofit = 53 | Retrofit.Builder() 54 | .baseUrl(MovieApi.API_URL) 55 | .addConverterFactory(GsonConverterFactory.create()) 56 | .client(okHttpClient) 57 | .build() 58 | 59 | @Singleton 60 | @Provides 61 | fun providesIntegrateApi(retrofit: Retrofit): MovieApi = 62 | retrofit.create(MovieApi::class.java) 63 | 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/ItemLazyLoad.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.wrapContentWidth 9 | import androidx.compose.material3.CircularProgressIndicator 10 | import androidx.compose.material3.MaterialTheme 11 | import androidx.compose.material3.OutlinedButton 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.res.stringResource 17 | import androidx.compose.ui.text.style.TextOverflow 18 | import androidx.compose.ui.unit.dp 19 | import com.mmj.movieapp.R 20 | 21 | @Composable 22 | fun PageLoader(modifier: Modifier = Modifier) { 23 | Column( 24 | modifier = modifier, 25 | verticalArrangement = Arrangement.Center, 26 | horizontalAlignment = Alignment.CenterHorizontally 27 | ) { 28 | Text( 29 | text = stringResource(id = R.string.strFetchingDataFromServer), 30 | color = MaterialTheme.colorScheme.primary, 31 | maxLines = 1, 32 | overflow = TextOverflow.Ellipsis 33 | ) 34 | CircularProgressIndicator(Modifier.padding(top = 10.dp)) 35 | } 36 | } 37 | 38 | @Composable 39 | fun LoadingNextPageItem(modifier: Modifier) { 40 | CircularProgressIndicator( 41 | modifier = modifier 42 | .fillMaxWidth() 43 | .padding(10.dp) 44 | .wrapContentWidth(Alignment.CenterHorizontally) 45 | ) 46 | } 47 | 48 | @Composable 49 | fun ErrorMessage( 50 | message: String, 51 | modifier: Modifier = Modifier, 52 | onClickRetry: () -> Unit 53 | ) { 54 | Row( 55 | modifier = modifier.padding(10.dp), 56 | horizontalArrangement = Arrangement.SpaceBetween, 57 | verticalAlignment = Alignment.CenterVertically 58 | ) { 59 | Text( 60 | text = message, 61 | color = MaterialTheme.colorScheme.error, 62 | modifier = Modifier.weight(1f), 63 | maxLines = 2 64 | ) 65 | OutlinedButton(onClick = onClickRetry) { 66 | Text(text = stringResource(id = R.string.strRetry)) 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/util/resource/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.util.resource.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | 18 | enum class AppTheme { 19 | Light, Dark, Default 20 | } 21 | 22 | private val DarkColorScheme = darkColorScheme( 23 | primary = colorPrimary, 24 | onPrimary = colorWhite, 25 | secondary = colorSecondary, 26 | onSecondary = colorWhite, 27 | background = colorBlack, 28 | onBackground = colorWhite, 29 | surface = colorGrayDark, 30 | onSurface = colorWhite100 31 | ) 32 | 33 | private val LightColorScheme = lightColorScheme( 34 | primary = colorPrimary, 35 | onPrimary = colorWhite, 36 | secondary = colorSecondary, 37 | onSecondary = colorWhite, 38 | background = colorWhite, 39 | onBackground = colorBlack, 40 | surface = colorWhite, 41 | onSurface = colorBlack100 42 | ) 43 | 44 | @Composable 45 | fun MovieTheme( 46 | appTheme: AppTheme, 47 | isDarkMode: Boolean = isSystemInDarkTheme(), 48 | dynamicColor: Boolean = true, 49 | content: @Composable () -> Unit 50 | ) { 51 | val colorScheme = when (appTheme) { 52 | AppTheme.Default -> { 53 | when { 54 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 55 | val context = LocalContext.current 56 | if (isDarkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 57 | } 58 | isDarkMode -> DarkColorScheme 59 | else -> LightColorScheme 60 | } 61 | } 62 | AppTheme.Light -> { 63 | LightColorScheme 64 | } 65 | AppTheme.Dark -> { 66 | DarkColorScheme 67 | } 68 | } 69 | 70 | val view = LocalView.current 71 | if (!view.isInEditMode) { 72 | SideEffect { 73 | val window = (view.context as Activity).window 74 | window.statusBarColor = colorScheme.primary.toArgb() 75 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = isDarkMode 76 | } 77 | } 78 | 79 | MaterialTheme( 80 | colorScheme = colorScheme, 81 | typography = Typography, 82 | content = content 83 | ) 84 | } -------------------------------------------------------------------------------- /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/res/drawable/ic_light_mode.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/mmj/movieapp/presentation/home/component/ItemMovie.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.home.component 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.foundation.Image 5 | import androidx.compose.foundation.clickable 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.shape.RoundedCornerShape 15 | import androidx.compose.material.icons.Icons 16 | import androidx.compose.material.icons.filled.Star 17 | import androidx.compose.material3.Card 18 | import androidx.compose.material3.CardDefaults 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.MaterialTheme 21 | import androidx.compose.material3.Text 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.runtime.getValue 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.alpha 27 | import androidx.compose.ui.draw.clip 28 | import androidx.compose.ui.graphics.Color 29 | import androidx.compose.ui.layout.ContentScale 30 | import androidx.compose.ui.text.style.TextOverflow 31 | import androidx.compose.ui.unit.dp 32 | import coil.compose.AsyncImagePainter 33 | import coil.compose.rememberAsyncImagePainter 34 | import com.mmj.movieapp.core.app.Constants 35 | import com.mmj.movieapp.domain.model.Movie 36 | 37 | @Composable 38 | fun ItemMovie( 39 | itemEntity: Movie, 40 | onClick: () -> Unit 41 | ) { 42 | Card( 43 | modifier = Modifier 44 | .padding(horizontal = 16.dp, vertical = 8.dp) 45 | .clip(RoundedCornerShape(8.dp)) 46 | .clickable { onClick() }, 47 | shape = RoundedCornerShape(8.dp), 48 | colors = CardDefaults.cardColors( 49 | containerColor = MaterialTheme.colorScheme.surface 50 | ), 51 | elevation = CardDefaults.cardElevation( 52 | defaultElevation = 8.dp 53 | ) 54 | ) { 55 | val painter = rememberAsyncImagePainter(Constants.IMAGE_URL_MOVIE + itemEntity.backdropPath) 56 | val transition by animateFloatAsState( 57 | targetValue = if (painter.state is AsyncImagePainter.State.Success) 1f else 0f 58 | ) 59 | Column { 60 | Box( 61 | modifier = Modifier 62 | .height(150.dp) 63 | .fillMaxWidth() 64 | ) { 65 | Image( 66 | painter = painter, 67 | contentDescription = null, 68 | contentScale = ContentScale.Crop, 69 | modifier = Modifier 70 | .fillMaxSize() 71 | .clip(RoundedCornerShape(8.dp)) 72 | .alpha(transition) 73 | ) 74 | Row( 75 | modifier = Modifier 76 | .padding(horizontal = 16.dp, vertical = 8.dp) 77 | .align(Alignment.TopEnd), 78 | verticalAlignment = Alignment.CenterVertically 79 | ) { 80 | Icon( 81 | Icons.Default.Star, 82 | contentDescription = null, 83 | tint = Color.Yellow 84 | ) 85 | 86 | Text( 87 | text = itemEntity.voteAverage.toString() + "/10", 88 | style = MaterialTheme.typography.titleSmall, 89 | color = Color.Yellow, 90 | modifier = Modifier.padding(start = 5.dp) 91 | ) 92 | } 93 | } 94 | 95 | Text( 96 | text = itemEntity.title, 97 | color = MaterialTheme.colorScheme.onBackground, 98 | style = MaterialTheme.typography.titleMedium, 99 | maxLines = 1, 100 | overflow = TextOverflow.Ellipsis, 101 | modifier = Modifier 102 | .fillMaxWidth() 103 | .padding(horizontal = 16.dp) 104 | .padding(top = 8.dp) 105 | ) 106 | 107 | Spacer(modifier = Modifier.padding(vertical = 4.dp)) 108 | 109 | Text( 110 | text = itemEntity.overview, 111 | modifier = Modifier 112 | .padding(horizontal = 16.dp) 113 | .padding(bottom = 8.dp), 114 | color = MaterialTheme.colorScheme.onSurface, 115 | maxLines = 2, 116 | overflow = TextOverflow.Ellipsis 117 | ) 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'kotlin-kapt' 5 | id 'com.google.dagger.hilt.android' 6 | id 'kotlin-parcelize' 7 | } 8 | 9 | android { 10 | signingConfigs { 11 | release { 12 | if (project.hasProperty('MYAPP_UPLOAD_STORE_FILE')) { 13 | storeFile file(MYAPP_UPLOAD_STORE_FILE) 14 | storePassword MYAPP_UPLOAD_STORE_PASSWORD 15 | keyAlias MYAPP_UPLOAD_KEY_ALIAS 16 | keyPassword MYAPP_UPLOAD_KEY_PASSWORD 17 | } 18 | 19 | // Optional, specify signing versions used 20 | v1SigningEnabled true 21 | v2SigningEnabled true 22 | } 23 | } 24 | 25 | namespace 'com.mmj.movieapp' 26 | compileSdk project.compileSdkVersion 27 | 28 | defaultConfig { 29 | applicationId "com.mmj.movieapp" 30 | minSdk project.minSdkVersion 31 | targetSdk project.targetSdkVersion 32 | versionCode 1 33 | versionName "1.0" 34 | 35 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 36 | vectorDrawables { 37 | useSupportLibrary true 38 | } 39 | } 40 | 41 | buildTypes { 42 | release { 43 | signingConfig signingConfigs.release 44 | minifyEnabled true 45 | shrinkResources true 46 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 47 | debuggable false 48 | } 49 | debug { 50 | minifyEnabled false 51 | shrinkResources false 52 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 53 | } 54 | } 55 | compileOptions { 56 | sourceCompatibility JavaVersion.VERSION_17 57 | targetCompatibility JavaVersion.VERSION_17 58 | } 59 | kotlinOptions { 60 | jvmTarget = '17' 61 | } 62 | buildFeatures { 63 | compose true 64 | } 65 | composeOptions { 66 | kotlinCompilerExtensionVersion project.kotlinCompilerExtensionVersion 67 | } 68 | packagingOptions { 69 | resources { 70 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 71 | } 72 | } 73 | } 74 | 75 | dependencies { 76 | 77 | // Kotlin 78 | implementation "androidx.core:core-ktx:$ktxVersion" 79 | 80 | // Lifecycle 81 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" 82 | 83 | // AndroidX Compose 84 | implementation platform("androidx.compose:compose-bom:$androidXComposeVersion") 85 | androidTestImplementation platform("androidx.compose:compose-bom:$androidXComposeVersion") 86 | 87 | // Compose Ui 88 | implementation 'androidx.compose.ui:ui' 89 | implementation 'androidx.compose.ui:ui-graphics' 90 | implementation 'androidx.compose.ui:ui-tooling-preview' 91 | androidTestImplementation 'androidx.compose.ui:ui-test-junit4' 92 | debugImplementation 'androidx.compose.ui:ui-tooling' 93 | implementation "androidx.activity:activity-compose:$composeActivityVersion" 94 | 95 | // Manifest 96 | debugImplementation 'androidx.compose.ui:ui-test-manifest' 97 | 98 | // Material3 99 | implementation 'androidx.compose.material3:material3' 100 | 101 | // Test 102 | testImplementation "junit:junit:$junitVersion" 103 | androidTestImplementation "androidx.test.ext:junit:$testExtJunitVersion" 104 | androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" 105 | 106 | //Navigation 107 | implementation "com.google.accompanist:accompanist-navigation-animation:$navAnimationVersion" 108 | implementation "androidx.navigation:navigation-compose:$navigationVersion" 109 | implementation "androidx.navigation:navigation-runtime-ktx:$navigationVersion" 110 | 111 | // System UI Controller 112 | implementation "com.google.accompanist:accompanist-systemuicontroller:$systemUIControllerVersion" 113 | 114 | //Dagger - Hilt 115 | implementation "com.google.dagger:hilt-android:$hiltVersion" 116 | kapt "com.google.dagger:hilt-android-compiler:$hiltVersion" 117 | kapt "androidx.hilt:hilt-compiler:1.0.0" 118 | implementation "androidx.hilt:hilt-navigation-compose:$hiltAndroidXVersion" 119 | 120 | // Retrofit 121 | implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" 122 | implementation "com.squareup.retrofit2:converter-moshi:$retrofitVersion" 123 | implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" 124 | 125 | // OkHttp 126 | implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" 127 | implementation "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" 128 | 129 | // Moshi 130 | implementation "com.squareup.moshi:moshi:$moshiVersion" 131 | kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion" 132 | 133 | // Kotlin Coroutines 134 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" 135 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" 136 | 137 | // Paper 138 | implementation "io.github.pilgr:paperdb:$paperVersion" 139 | 140 | // Lottie 141 | implementation "com.airbnb.android:lottie-compose:$lottieVersion" 142 | 143 | //paging compose 144 | implementation "androidx.paging:paging-runtime:$pagingVersion" 145 | testImplementation "androidx.paging:paging-common:$pagingVersion" 146 | implementation 'androidx.paging:paging-compose:1.0.0-alpha20' 147 | 148 | implementation "io.coil-kt:coil-compose:2.4.0" 149 | } -------------------------------------------------------------------------------- /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/mmj/movieapp/presentation/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.mmj.movieapp.presentation.home 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Row 5 | import androidx.compose.foundation.layout.Spacer 6 | import androidx.compose.foundation.layout.fillMaxWidth 7 | import androidx.compose.foundation.layout.padding 8 | import androidx.compose.foundation.layout.size 9 | import androidx.compose.foundation.layout.wrapContentHeight 10 | import androidx.compose.foundation.lazy.LazyColumn 11 | import androidx.compose.material.icons.Icons 12 | import androidx.compose.material.icons.filled.Menu 13 | import androidx.compose.material3.Icon 14 | import androidx.compose.material3.IconButton 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Scaffold 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.ui.Alignment 20 | import androidx.compose.ui.Modifier 21 | import androidx.compose.ui.res.painterResource 22 | import androidx.compose.ui.res.stringResource 23 | import androidx.compose.ui.text.style.TextAlign 24 | import androidx.compose.ui.unit.dp 25 | import androidx.hilt.navigation.compose.hiltViewModel 26 | import androidx.navigation.NavController 27 | import androidx.paging.LoadState 28 | import androidx.paging.compose.LazyPagingItems 29 | import androidx.paging.compose.collectAsLazyPagingItems 30 | import com.mmj.movieapp.R 31 | import com.mmj.movieapp.core.app.AppPreferences 32 | import com.mmj.movieapp.domain.model.Movie 33 | import com.mmj.movieapp.presentation.home.component.ItemMovie 34 | import com.mmj.movieapp.presentation.main.MainEvent 35 | import com.mmj.movieapp.presentation.main.MainViewModel 36 | import com.mmj.movieapp.presentation.util.ErrorMessage 37 | import com.mmj.movieapp.presentation.util.LoadingNextPageItem 38 | import com.mmj.movieapp.presentation.util.PageLoader 39 | import com.mmj.movieapp.presentation.util.resource.route.AppScreen 40 | import com.mmj.movieapp.presentation.util.resource.theme.AppTheme 41 | 42 | @Composable 43 | fun HomeScreen( 44 | mainViewModel: MainViewModel, 45 | navController: NavController, 46 | viewModel: HomeViewModel = hiltViewModel() 47 | ) { 48 | val moviePagingItems: LazyPagingItems = viewModel.moviesState.collectAsLazyPagingItems() 49 | Scaffold( 50 | topBar = { 51 | Row( 52 | modifier = Modifier 53 | .fillMaxWidth() 54 | .wrapContentHeight() 55 | .background(MaterialTheme.colorScheme.primary), 56 | verticalAlignment = Alignment.CenterVertically 57 | ) { 58 | IconButton( 59 | onClick = { 60 | 61 | } 62 | ) { 63 | Icon( 64 | Icons.Default.Menu, 65 | contentDescription = null, 66 | tint = MaterialTheme.colorScheme.onPrimary, 67 | modifier = Modifier.size(25.dp) 68 | ) 69 | } 70 | 71 | Text( 72 | text = stringResource(id = R.string.app_name), 73 | color = MaterialTheme.colorScheme.onPrimary, 74 | modifier = Modifier 75 | .padding(vertical = 16.dp, horizontal = 16.dp) 76 | .weight(1.0f), 77 | textAlign = TextAlign.Center 78 | ) 79 | 80 | IconButton( 81 | onClick = { 82 | if (mainViewModel.stateApp.theme == AppTheme.Light) { 83 | AppPreferences.setTheme(AppTheme.Dark) 84 | mainViewModel.onEvent(MainEvent.ThemeChange(AppTheme.Dark)) 85 | } else { 86 | AppPreferences.setTheme(AppTheme.Light) 87 | mainViewModel.onEvent(MainEvent.ThemeChange(AppTheme.Light)) 88 | } 89 | } 90 | ) { 91 | Icon( 92 | painter = if (mainViewModel.stateApp.theme == AppTheme.Light) 93 | painterResource(id = R.drawable.ic_dark_mode) 94 | else 95 | painterResource(id = R.drawable.ic_light_mode), 96 | contentDescription = null, 97 | tint = MaterialTheme.colorScheme.onPrimary, 98 | modifier = Modifier.size(25.dp) 99 | ) 100 | } 101 | } 102 | } 103 | ) { 104 | LazyColumn( 105 | modifier = Modifier.padding(it) 106 | ) { 107 | item { Spacer(modifier = Modifier.padding(4.dp)) } 108 | items(moviePagingItems.itemCount) { index -> 109 | ItemMovie( 110 | itemEntity = moviePagingItems[index]!!, 111 | onClick = { 112 | navController.navigate(AppScreen.DetailsScreen.route) 113 | } 114 | ) 115 | } 116 | moviePagingItems.apply { 117 | when { 118 | loadState.refresh is LoadState.Loading -> { 119 | item { PageLoader(modifier = Modifier.fillParentMaxSize()) } 120 | } 121 | 122 | loadState.refresh is LoadState.Error -> { 123 | val error = moviePagingItems.loadState.refresh as LoadState.Error 124 | item { 125 | ErrorMessage( 126 | modifier = Modifier.fillParentMaxSize(), 127 | message = error.error.localizedMessage!!, 128 | onClickRetry = { retry() }) 129 | } 130 | } 131 | 132 | loadState.append is LoadState.Loading -> { 133 | item { LoadingNextPageItem(modifier = Modifier) } 134 | } 135 | 136 | loadState.append is LoadState.Error -> { 137 | val error = moviePagingItems.loadState.append as LoadState.Error 138 | item { 139 | ErrorMessage( 140 | modifier = Modifier, 141 | message = error.error.localizedMessage!!, 142 | onClickRetry = { retry() }) 143 | } 144 | } 145 | } 146 | } 147 | item { Spacer(modifier = Modifier.padding(4.dp)) } 148 | } 149 | } 150 | } --------------------------------------------------------------------------------