├── .gitignore ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ ├── assets │ │ └── popular_movies.json │ └── java │ │ └── com │ │ └── devexperto │ │ └── architectcoders │ │ ├── data │ │ └── server │ │ │ ├── MockWebServerRule.kt │ │ │ └── mockResponseExtensions.kt │ │ ├── di │ │ ├── HiltTestRunner.kt │ │ └── TestAppModule.kt │ │ └── ui │ │ ├── MainInstrumentationTest.kt │ │ └── OkHttp3IdlingResource.kt │ ├── debug │ ├── AndroidManifest.xml │ └── res │ │ └── xml │ │ └── network_security_config.xml │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── devexperto │ │ │ └── architectcoders │ │ │ ├── App.kt │ │ │ ├── data │ │ │ ├── AndroidPermissionChecker.kt │ │ │ ├── PlayServicesLocationDataSource.kt │ │ │ ├── database │ │ │ │ ├── Movie.kt │ │ │ │ ├── MovieDao.kt │ │ │ │ ├── MovieDatabase.kt │ │ │ │ └── MovieRoomDataSource.kt │ │ │ ├── extensions.kt │ │ │ └── server │ │ │ │ ├── MovieServerDataSource.kt │ │ │ │ ├── RemoteResult.kt │ │ │ │ └── RemoteService.kt │ │ │ ├── di │ │ │ ├── ApiKey.kt │ │ │ ├── ApiUrl.kt │ │ │ ├── AppModule.kt │ │ │ └── MovieId.kt │ │ │ └── ui │ │ │ ├── NavHostActivity.kt │ │ │ ├── common │ │ │ ├── AspectRatioImageView.kt │ │ │ ├── BindingAdapters.kt │ │ │ ├── PermissionRequester.kt │ │ │ └── extensions.kt │ │ │ ├── detail │ │ │ ├── DetailBindingAdapters.kt │ │ │ ├── DetailFragment.kt │ │ │ ├── DetailViewModel.kt │ │ │ ├── MovieDetailInfoView.kt │ │ │ └── di.kt │ │ │ └── main │ │ │ ├── MainBindingAdapters.kt │ │ │ ├── MainFragment.kt │ │ │ ├── MainState.kt │ │ │ ├── MainViewModel.kt │ │ │ └── MoviesAdapter.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ ├── ic_arrow_back.xml │ │ ├── ic_favorite_off.xml │ │ ├── ic_favorite_on.xml │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_nav_host.xml │ │ ├── fragment_detail.xml │ │ ├── fragment_main.xml │ │ └── view_movie.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── 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 │ │ ├── navigation │ │ └── nav_graph.xml │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── api_key.xml │ │ ├── attrs.xml │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── devexperto │ └── architectcoders │ ├── testrules │ └── CoroutinesTestRule.kt │ └── ui │ ├── detail │ ├── DetailIntegrationTests.kt │ └── DetailViewModelTest.kt │ └── main │ ├── MainIntegrationTests.kt │ └── MainViewModelTest.kt ├── appTestShared ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── com │ └── devexperto │ └── architectcoders │ └── appTestShared │ ├── fakes.kt │ └── helpers.kt ├── build.gradle ├── buildSrc ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── devexperto │ └── architectcoders │ └── buildsrc │ └── dependencies.kt ├── data ├── .gitignore ├── build.gradle └── src │ ├── main │ └── java │ │ └── com │ │ └── devexperto │ │ └── architectcoders │ │ └── data │ │ ├── MoviesRepository.kt │ │ ├── PermissionChecker.kt │ │ ├── RegionRepository.kt │ │ └── datasource │ │ ├── LocationDataSource.kt │ │ ├── MovieLocalDataSource.kt │ │ └── MovieRemoteDataSource.kt │ └── test │ └── java │ └── com │ └── devexperto │ └── architectcoders │ └── data │ ├── MoviesRepositoryTest.kt │ └── RegionRepositoryTest.kt ├── domain ├── .gitignore ├── build.gradle └── src │ └── main │ └── java │ └── com │ └── devexperto │ └── architectcoders │ └── domain │ ├── Error.kt │ └── Movie.kt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── testShared ├── .gitignore ├── build.gradle └── src │ └── main │ └── java │ └── com │ └── devexperto │ └── architectcoders │ └── testshared │ └── samples.kt └── usecases ├── .gitignore ├── build.gradle └── src ├── main └── java │ └── com │ └── devexperto │ └── architectcoders │ └── usecases │ ├── FindMovieUseCase.kt │ ├── GetPopularMoviesUseCase.kt │ ├── RequestPopularMoviesUseCase.kt │ └── SwitchMovieFavoriteUseCase.kt └── test └── java └── com └── devexperto └── architectcoders └── usecases ├── FindMovieUseCaseTest.kt ├── GetPopularMoviesUseCaseTest.kt ├── RequestPopularMoviesUseCaseTest.kt └── SwitchMovieFavoriteUseCaseTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | import com.devexperto.architectcoders.buildsrc.Libs 2 | 3 | plugins { 4 | id 'com.android.application' 5 | id 'org.jetbrains.kotlin.android' 6 | id 'org.jetbrains.kotlin.kapt' 7 | id 'kotlin-parcelize' 8 | id 'androidx.navigation.safeargs.kotlin' 9 | id 'dagger.hilt.android.plugin' 10 | } 11 | 12 | android { 13 | compileSdk 33 14 | 15 | defaultConfig { 16 | applicationId "com.devexperto.architectcoders" 17 | minSdk 24 18 | targetSdk 33 19 | versionCode 1 20 | versionName "1.0" 21 | 22 | testInstrumentationRunner "com.devexperto.architectcoders.di.HiltTestRunner" 23 | } 24 | 25 | buildTypes { 26 | release { 27 | minifyEnabled false 28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 29 | } 30 | } 31 | compileOptions { 32 | sourceCompatibility JavaVersion.VERSION_17 33 | targetCompatibility JavaVersion.VERSION_17 34 | } 35 | kotlinOptions { 36 | jvmTarget = '17' 37 | } 38 | buildFeatures { 39 | dataBinding true 40 | } 41 | namespace 'com.devexperto.architectcoders' 42 | } 43 | 44 | dependencies { 45 | implementation project(":data") 46 | implementation project(":domain") 47 | implementation project(":usecases") 48 | 49 | implementation Libs.AndroidX.coreKtx 50 | implementation Libs.AndroidX.appCompat 51 | implementation Libs.AndroidX.recyclerView 52 | implementation Libs.AndroidX.material 53 | implementation Libs.AndroidX.constraintLayout 54 | 55 | implementation Libs.AndroidX.Activity.ktx 56 | 57 | implementation Libs.AndroidX.Lifecycle.viewmodelKtx 58 | implementation Libs.AndroidX.Lifecycle.runtimeKtx 59 | 60 | implementation Libs.AndroidX.Navigation.fragmentKtx 61 | implementation Libs.AndroidX.Navigation.uiKtx 62 | 63 | implementation Libs.AndroidX.Room.runtime 64 | implementation Libs.AndroidX.Room.ktx 65 | kapt Libs.AndroidX.Room.compiler 66 | 67 | implementation Libs.playServicesLocation 68 | 69 | implementation Libs.Glide.glide 70 | kapt Libs.Glide.compiler 71 | 72 | implementation Libs.OkHttp3.loginInterceptor 73 | implementation Libs.Retrofit.retrofit 74 | implementation Libs.Retrofit.converterGson 75 | 76 | implementation Libs.Arrow.core 77 | 78 | implementation Libs.Hilt.android 79 | kapt Libs.Hilt.compiler 80 | 81 | testImplementation project(":testShared") 82 | testImplementation project(":appTestShared") 83 | 84 | testImplementation Libs.JUnit.junit 85 | testImplementation Libs.Mockito.kotlin 86 | testImplementation Libs.Mockito.inline 87 | testImplementation Libs.Kotlin.Coroutines.test 88 | testImplementation Libs.turbine 89 | 90 | androidTestImplementation project(":appTestShared") 91 | androidTestImplementation Libs.AndroidX.Test.Ext.junit 92 | androidTestImplementation Libs.AndroidX.Test.Espresso.contrib 93 | androidTestImplementation Libs.AndroidX.Test.runner 94 | androidTestImplementation Libs.AndroidX.Test.rules 95 | androidTestImplementation Libs.Hilt.test 96 | androidTestImplementation Libs.Kotlin.Coroutines.test 97 | kaptAndroidTest Libs.Hilt.compiler 98 | 99 | androidTestImplementation Libs.OkHttp3.mockWebServer 100 | } 101 | 102 | kapt { 103 | correctErrorTypes true 104 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /app/src/androidTest/assets/popular_movies.json: -------------------------------------------------------------------------------- 1 | {"page":1,"results":[{"adult":false,"backdrop_path":"/5P8SmMzSNYikXpxil6BYzJ16611.jpg","genre_ids":[80,9648,53],"id":414906,"original_language":"en","original_title":"The Batman","overview":"In his second year of fighting crime, Batman uncovers corruption in Gotham City that connects to his own family while facing a serial killer known as the Riddler.","popularity":11286.255,"poster_path":"/74xTEgt7R36Fpooo50r9T25onhq.jpg","release_date":"2022-03-04","title":"The Batman","video":false,"vote_average":7.8,"vote_count":3844},{"adult":false,"backdrop_path":"/2n95p9isIi1LYTscTcGytlI4zYd.jpg","genre_ids":[18,53,80],"id":799876,"original_language":"en","original_title":"The Outfit","overview":"Leonard is an English tailor who used to craft suits on London’s world-famous Savile Row. After a personal tragedy, he’s ended up in Chicago, operating a small tailor shop in a rough part of town where he makes beautiful clothes for the only people around who can afford them: a family of vicious gangsters.","popularity":3847.22,"poster_path":"/mBUoNT1nJ2dK53PXRSUOyoPez8S.jpg","release_date":"2022-03-18","title":"The Outfit","video":false,"vote_average":6.9,"vote_count":125},{"adult":false,"backdrop_path":"/iQFcwSGbZXMkeyKrxbPnwnRo5fl.jpg","genre_ids":[28,12,878],"id":634649,"original_language":"en","original_title":"Spider-Man: No Way Home","overview":"Peter Parker is unmasked and no longer able to separate his normal life from the high-stakes of being a super-hero. When he asks for help from Doctor Strange the stakes become even more dangerous, forcing him to discover what it truly means to be Spider-Man.","popularity":4469.989,"poster_path":"/1g0dhYtq4irTY1GPXvft6k4YLjm.jpg","release_date":"2021-12-17","title":"Spider-Man: No Way Home","video":false,"vote_average":8.1,"vote_count":12064},{"adult":false,"backdrop_path":"/fOy2Jurz9k6RnJnMUMRDAgBwru2.jpg","genre_ids":[16,10751,35,14],"id":508947,"original_language":"en","original_title":"Turning Red","overview":"Thirteen-year-old Mei is experiencing the awkwardness of being a teenager with a twist – when she gets too excited, she transforms into a giant red panda.","popularity":3359.913,"poster_path":"/qsdjk9oAKSQMWs0Vt5Pyfh6O4GZ.jpg","release_date":"2022-03-01","title":"Turning Red","video":false,"vote_average":7.5,"vote_count":1889},{"adult":false,"backdrop_path":"/egoyMDLqCxzjnSrWOz50uLlJWmD.jpg","genre_ids":[28,878,35,10751],"id":675353,"original_language":"en","original_title":"Sonic the Hedgehog 2","overview":"After settling in Green Hills, Sonic is eager to prove he has what it takes to be a true hero. His test comes when Dr. Robotnik returns, this time with a new partner, Knuckles, in search for an emerald that has the power to destroy civilizations. Sonic teams up with his own sidekick, Tails, and together they embark on a globe-trotting journey to find the emerald before it falls into the wrong hands.","popularity":3053.837,"poster_path":"/6DrHO1jr3qVrViUO6s6kFiAGM7.jpg","release_date":"2022-04-08","title":"Sonic the Hedgehog 2","video":false,"vote_average":7.6,"vote_count":700},{"adult":false,"backdrop_path":"/x747ZvF0CcYYTTpPRCoUrxA2cYy.jpg","genre_ids":[28,12,878],"id":406759,"original_language":"en","original_title":"Moonfall","overview":"A mysterious force knocks the moon from its orbit around Earth and sends it hurtling on a collision course with life as we know it.","popularity":2287.837,"poster_path":"/odVv1sqVs0KxBXiA8bhIBlPgalx.jpg","release_date":"2022-02-04","title":"Moonfall","video":false,"vote_average":6.5,"vote_count":796},{"adult":false,"backdrop_path":"/xicKILMzPn6XZYCOpWwaxlUzg6S.jpg","genre_ids":[53,28],"id":294793,"original_language":"en","original_title":"All the Old Knives","overview":"When the CIA discovers one of its agents leaked information that cost more than 100 people their lives, veteran operative Henry Pelham is assigned to root out the mole with his former lover and colleague Celia Harrison.","popularity":2199.058,"poster_path":"/g4tMniKxol1TBJrHlAtiDjjlx4Q.jpg","release_date":"2022-04-08","title":"All the Old Knives","video":false,"vote_average":6,"vote_count":168},{"adult":false,"backdrop_path":"/aEGiJJP91HsKVTEPy1HhmN0wRLm.jpg","genre_ids":[28,12],"id":335787,"original_language":"en","original_title":"Uncharted","overview":"A young street-smart, Nathan Drake and his wisecracking partner Victor “Sully” Sullivan embark on a dangerous pursuit of “the greatest treasure never found” while also tracking clues that may lead to Nathan’s long-lost brother.","popularity":2269.063,"poster_path":"/sqLowacltbZLoCa4KYye64RvvdQ.jpg","release_date":"2022-02-18","title":"Uncharted","video":false,"vote_average":7.2,"vote_count":1259},{"adult":false,"backdrop_path":"/dqWiut9F30jkiKHHkYTf2RIy1g7.jpg","genre_ids":[878,28],"id":919689,"original_language":"en","original_title":"War of the Worlds: Annihilation","overview":"A mother and son find themselves faced with a brutal alien invasion where survival will depend on discovering the unthinkable truth about the enemy.","popularity":1487.668,"poster_path":"/9eiUNsUAw2iwVyMeXNNiNQQad4E.jpg","release_date":"2021-12-22","title":"War of the Worlds: Annihilation","video":false,"vote_average":5.9,"vote_count":33},{"adult":false,"backdrop_path":"/3G1Q5xF40HkUBJXxt2DQgQzKTp5.jpg","genre_ids":[16,35,10751,14],"id":568124,"original_language":"en","original_title":"Encanto","overview":"The tale of an extraordinary family, the Madrigals, who live hidden in the mountains of Colombia, in a magical house, in a vibrant town, in a wondrous, charmed place called an Encanto. The magic of the Encanto has blessed every child in the family with a unique gift from super strength to the power to heal—every child except one, Mirabel. But when she discovers that the magic surrounding the Encanto is in danger, Mirabel decides that she, the only ordinary Madrigal, might just be her exceptional family's last hope.","popularity":1582.736,"poster_path":"/4j0PNHkMr5ax3IA8tjtxcmPU3QT.jpg","release_date":"2021-11-24","title":"Encanto","video":false,"vote_average":7.7,"vote_count":6195},{"adult":false,"backdrop_path":"/iDeWAGnmloZ5Oz3bocDp4rSbUXd.jpg","genre_ids":[28,53],"id":823625,"original_language":"en","original_title":"Blacklight","overview":"Travis Block is a shadowy Government agent who specializes in removing operatives whose covers have been exposed. He then has to uncover a deadly conspiracy within his own ranks that reaches the highest echelons of power.","popularity":1633.234,"poster_path":"/bv9dy8mnwftdY2j6gG39gCfSFpV.jpg","release_date":"2022-02-11","title":"Blacklight","video":false,"vote_average":6.1,"vote_count":297},{"adult":false,"backdrop_path":"/ewUqXnwiRLhgmGhuksOdLgh49Ch.jpg","genre_ids":[28,12,35,878],"id":696806,"original_language":"en","original_title":"The Adam Project","overview":"After accidentally crash-landing in 2022, time-traveling fighter pilot Adam Reed teams up with his 12-year-old self on a mission to save the future.","popularity":1367.691,"poster_path":"/wFjboE0aFZNbVOF05fzrka9Fqyx.jpg","release_date":"2022-03-11","title":"The Adam Project","video":false,"vote_average":7,"vote_count":1956},{"adult":false,"backdrop_path":"/33wnBK5NxvuKQv0Cxo3wMv0eR7F.jpg","genre_ids":[27,53],"id":833425,"original_language":"en","original_title":"No Exit","overview":"Stranded at a rest stop in the mountains during a blizzard, a recovering addict discovers a kidnapped child hidden in a car belonging to one of the people inside the building which sets her on a terrifying struggle to identify who among them is the kidnapper.","popularity":1200.072,"poster_path":"/5cnLoWq9o5tuLe1Zq4BTX4LwZ2B.jpg","release_date":"2022-02-25","title":"No Exit","video":false,"vote_average":6.7,"vote_count":396},{"adult":false,"backdrop_path":"/yzH5zvuEzzsHLZnn0jwYoPf0CMT.jpg","genre_ids":[53,28],"id":760926,"original_language":"en","original_title":"Gold","overview":"In the not-too-distant future, two drifters traveling through the desert stumble across the biggest gold nugget ever found and the dream of immense wealth and greed takes hold. They hatch a plan to excavate their bounty, with one man leaving to secure the necessary tools while the other remains with the gold. The man who remains must endure harsh desert elements, ravenous wild dogs, and mysterious intruders, while battling the sinking suspicion that he has been abandoned to his fate.","popularity":1148.918,"poster_path":"/ejXBuNLvK4kZ7YcqeKqUWnCxdJq.jpg","release_date":"2022-03-11","title":"Gold","video":false,"vote_average":6.4,"vote_count":206},{"adult":false,"backdrop_path":"/t7I942V5U1Ggn6OevN75u3sNYH9.jpg","genre_ids":[28,53],"id":760868,"original_language":"sv","original_title":"Svart krabba","overview":"To end an apocalyptic war and save her daughter, a reluctant soldier embarks on a desperate mission to cross a frozen sea carrying a top-secret cargo.","popularity":1052.163,"poster_path":"/mcIYHZYwUbvhvUt8Lb5nENJ7AlX.jpg","release_date":"2022-03-18","title":"Black Crab","video":false,"vote_average":6.2,"vote_count":365},{"adult":false,"backdrop_path":"/qBLEWvJNVsehJkEJqIigPsWyBse.jpg","genre_ids":[16,10751,14,35,12],"id":585083,"original_language":"en","original_title":"Hotel Transylvania: Transformania","overview":"When Van Helsing's mysterious invention, the \"Monsterfication Ray,\" goes haywire, Drac and his monster pals are all transformed into humans, and Johnny becomes a monster. In their new mismatched bodies, Drac and Johnny must team up and race across the globe to find a cure before it's too late, and before they drive each other crazy.","popularity":1051.177,"poster_path":"/teCy1egGQa0y8ULJvlrDHQKnxBL.jpg","release_date":"2022-02-25","title":"Hotel Transylvania: Transformania","video":false,"vote_average":7.1,"vote_count":757},{"adult":false,"backdrop_path":"/7CamWBejQ9JQOO5vAghZfrFpMXY.jpg","genre_ids":[28,53,80],"id":928381,"original_language":"fr","original_title":"Sans répit","overview":"After going to extremes to cover up an accident, a corrupt cop's life spirals out of control when he starts receiving threats from a mysterious witness.","popularity":977.182,"poster_path":"/9MP21x0OPv0R72obd63tMHssmGt.jpg","release_date":"2022-02-25","title":"Restless","video":false,"vote_average":5.9,"vote_count":233},{"adult":false,"backdrop_path":"/i9rEpTqC6aIQOWOc4PDEEAE3hFe.jpg","genre_ids":[10749,878,18],"id":818750,"original_language":"en","original_title":"The In Between","overview":"After surviving a car accident that took the life of her boyfriend, a teenage girl believes he's attempting to reconnect with her from the after world.","popularity":937.009,"poster_path":"/7RcyjraM1cB1Uxy2W9ZWrab4KCw.jpg","release_date":"2022-02-11","title":"The In Between","video":false,"vote_average":7.1,"vote_count":214},{"adult":false,"backdrop_path":"/cugmVwK0N4aAcLibelKN5jWDXSx.jpg","genre_ids":[16,28,14,12],"id":768744,"original_language":"ja","original_title":"僕のヒーローアカデミア THE MOVIE ワールド ヒーローズ ミッション","overview":"A mysterious group called Humarize strongly believes in the Quirk Singularity Doomsday theory which states that when quirks get mixed further in with future generations, that power will bring forth the end of humanity. In order to save everyone, the Pro-Heroes around the world ask UA Academy heroes-in-training to assist them and form a world-classic selected hero team. It is up to the heroes to save the world and the future of heroes in what is the most dangerous crisis to take place yet in My Hero Academia.","popularity":948.281,"poster_path":"/4NUzcKtYPKkfTwKsLjwNt8nRIXV.jpg","release_date":"2021-10-29","title":"My Hero Academia: World Heroes' Mission","video":false,"vote_average":7.2,"vote_count":152},{"adult":false,"backdrop_path":"/vIgyYkXkg6NC2whRbYjBD7eb3Er.jpg","genre_ids":[878,28,12],"id":580489,"original_language":"en","original_title":"Venom: Let There Be Carnage","overview":"After finding a host body in investigative reporter Eddie Brock, the alien symbiote must face a new enemy, Carnage, the alter ego of serial killer Cletus Kasady.","popularity":957.508,"poster_path":"/rjkmN1dniUHVYAtwuV3Tji7FsDO.jpg","release_date":"2021-10-01","title":"Venom: Let There Be Carnage","video":false,"vote_average":7,"vote_count":7058}],"total_pages":11126,"total_results":222505} -------------------------------------------------------------------------------- /app/src/androidTest/java/com/devexperto/architectcoders/data/server/MockWebServerRule.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.server 2 | 3 | import okhttp3.mockwebserver.MockWebServer 4 | import org.junit.rules.TestWatcher 5 | import org.junit.runner.Description 6 | 7 | class MockWebServerRule : TestWatcher() { 8 | 9 | lateinit var server: MockWebServer 10 | 11 | override fun starting(description: Description) { 12 | server = MockWebServer() 13 | server.start(8080) 14 | } 15 | 16 | override fun finished(description: Description) { 17 | server.shutdown() 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/devexperto/architectcoders/data/server/mockResponseExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import okhttp3.mockwebserver.MockResponse 5 | import java.io.BufferedReader 6 | import java.io.InputStreamReader 7 | import java.nio.charset.StandardCharsets 8 | 9 | fun MockResponse.fromJson(jsonFile: String): MockResponse = 10 | setBody(readJsonFile(jsonFile)) 11 | 12 | private fun readJsonFile(jsonFilePath: String): String { 13 | val context = InstrumentationRegistry.getInstrumentation().context 14 | 15 | var br: BufferedReader? = null 16 | 17 | try { 18 | br = BufferedReader( 19 | InputStreamReader( 20 | context.assets.open( 21 | jsonFilePath 22 | ), StandardCharsets.UTF_8 23 | ) 24 | ) 25 | var line: String? 26 | val text = StringBuilder() 27 | 28 | do { 29 | line = br.readLine() 30 | line?.let { text.append(line) } 31 | } while (line != null) 32 | br.close() 33 | return text.toString() 34 | } finally { 35 | br?.close() 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/devexperto/architectcoders/di/HiltTestRunner.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.di 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.runner.AndroidJUnitRunner 6 | import dagger.hilt.android.testing.HiltTestApplication 7 | 8 | // A custom runner to set up the instrumented application class for tests. 9 | @Suppress("unused") 10 | class HiltTestRunner : AndroidJUnitRunner() { 11 | 12 | override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { 13 | return super.newApplication(cl, HiltTestApplication::class.java.name, context) 14 | } 15 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/devexperto/architectcoders/di/TestAppModule.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.di 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import com.devexperto.architectcoders.R 6 | import com.devexperto.architectcoders.data.database.MovieDatabase 7 | import com.devexperto.architectcoders.data.server.RemoteService 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.components.SingletonComponent 11 | import dagger.hilt.testing.TestInstallIn 12 | import okhttp3.OkHttpClient 13 | import okhttp3.logging.HttpLoggingInterceptor 14 | import retrofit2.Retrofit 15 | import retrofit2.converter.gson.GsonConverterFactory 16 | import retrofit2.create 17 | import javax.inject.Singleton 18 | 19 | @Module 20 | @TestInstallIn( 21 | components = [SingletonComponent::class], 22 | replaces = [AppModule::class] 23 | ) 24 | object TestAppModule { 25 | 26 | @Provides 27 | @Singleton 28 | @ApiKey 29 | fun provideApiKey(app: Application): String = app.getString(R.string.api_key) 30 | 31 | @Provides 32 | @Singleton 33 | fun provideDatabase(app: Application) = Room.inMemoryDatabaseBuilder( 34 | app, 35 | MovieDatabase::class.java 36 | ).build() 37 | 38 | @Provides 39 | @Singleton 40 | fun provideMovieDao(db: MovieDatabase) = db.movieDao() 41 | 42 | @Provides 43 | @Singleton 44 | @ApiUrl 45 | fun provideApiUrl(): String = "http://localhost:8080" 46 | 47 | @Provides 48 | @Singleton 49 | fun provideOkHttpClient(): OkHttpClient = HttpLoggingInterceptor().run { 50 | level = HttpLoggingInterceptor.Level.BODY 51 | OkHttpClient.Builder().addInterceptor(this).build() 52 | } 53 | 54 | @Provides 55 | @Singleton 56 | fun provideRemoteService(@ApiUrl apiUrl: String, okHttpClient: OkHttpClient): RemoteService { 57 | return Retrofit.Builder() 58 | .baseUrl(apiUrl) 59 | .client(okHttpClient) 60 | .addConverterFactory(GsonConverterFactory.create()) 61 | .build() 62 | .create() 63 | } 64 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/devexperto/architectcoders/ui/MainInstrumentationTest.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui 2 | 3 | import androidx.recyclerview.widget.RecyclerView 4 | import androidx.test.espresso.Espresso.onView 5 | import androidx.test.espresso.IdlingRegistry 6 | import androidx.test.espresso.action.ViewActions.click 7 | import androidx.test.espresso.assertion.ViewAssertions.matches 8 | import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition 9 | import androidx.test.espresso.matcher.ViewMatchers.* 10 | import androidx.test.ext.junit.rules.ActivityScenarioRule 11 | import androidx.test.rule.GrantPermissionRule 12 | import com.devexperto.architectcoders.R 13 | import com.devexperto.architectcoders.data.server.MockWebServerRule 14 | import com.devexperto.architectcoders.fromJson 15 | import dagger.hilt.android.testing.HiltAndroidRule 16 | import dagger.hilt.android.testing.HiltAndroidTest 17 | import kotlinx.coroutines.ExperimentalCoroutinesApi 18 | import okhttp3.OkHttpClient 19 | import okhttp3.mockwebserver.MockResponse 20 | import org.junit.Before 21 | import org.junit.Rule 22 | import org.junit.Test 23 | import javax.inject.Inject 24 | 25 | @ExperimentalCoroutinesApi 26 | @HiltAndroidTest 27 | class MainInstrumentationTest { 28 | 29 | @get:Rule(order = 0) 30 | val hiltRule = HiltAndroidRule(this) 31 | 32 | @get:Rule(order = 1) 33 | val mockWebServerRule = MockWebServerRule() 34 | 35 | @get:Rule(order = 2) 36 | val locationPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( 37 | "android.permission.ACCESS_COARSE_LOCATION" 38 | ) 39 | 40 | @get:Rule(order = 3) 41 | val activityRule = ActivityScenarioRule(NavHostActivity::class.java) 42 | 43 | @Inject 44 | lateinit var okHttpClient: OkHttpClient 45 | 46 | @Before 47 | fun setUp() { 48 | mockWebServerRule.server.enqueue( 49 | MockResponse().fromJson("popular_movies.json") 50 | ) 51 | 52 | hiltRule.inject() 53 | 54 | val resource = OkHttp3IdlingResource.create("OkHttp", okHttpClient) 55 | IdlingRegistry.getInstance().register(resource) 56 | } 57 | 58 | @Test 59 | fun click_a_movie_navigates_to_detail() { 60 | onView(withId(R.id.recycler)) 61 | .perform( 62 | actionOnItemAtPosition(4, click() 63 | ) 64 | ) 65 | 66 | onView(withId(R.id.movie_detail_toolbar)) 67 | .check(matches(hasDescendant(withText("Turning Red")))) 68 | 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/devexperto/architectcoders/ui/OkHttp3IdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui 2 | 3 | import androidx.annotation.CheckResult 4 | import androidx.test.espresso.IdlingResource 5 | import okhttp3.Dispatcher 6 | import okhttp3.OkHttpClient 7 | 8 | 9 | class OkHttp3IdlingResource private constructor( 10 | private val name: String, 11 | private val dispatcher: Dispatcher 12 | ) : 13 | IdlingResource { 14 | 15 | @Volatile 16 | var callback: IdlingResource.ResourceCallback? = null 17 | 18 | override fun getName(): String = name 19 | 20 | override fun isIdleNow(): Boolean = dispatcher.runningCallsCount() == 0 21 | 22 | override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { 23 | this.callback = callback 24 | } 25 | 26 | companion object { 27 | /** 28 | * Create a new [IdlingResource] from `client` as `name`. You must register 29 | * this instance using `Espresso.registerIdlingResources`. 30 | */ 31 | @CheckResult 32 | fun create(name: String, client: OkHttpClient): OkHttp3IdlingResource { 33 | return OkHttp3IdlingResource(name, client.dispatcher) 34 | } 35 | } 36 | 37 | init { 38 | dispatcher.idleCallback = Runnable { callback?.onTransitionToIdle() } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/debug/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | localhost 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/App.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class App : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/AndroidPermissionChecker.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data 2 | 3 | import android.Manifest 4 | import android.app.Application 5 | import android.content.pm.PackageManager 6 | import androidx.core.content.ContextCompat 7 | import javax.inject.Inject 8 | 9 | class AndroidPermissionChecker @Inject constructor(private val application: Application) : 10 | PermissionChecker { 11 | 12 | override fun check(permission: PermissionChecker.Permission): Boolean = 13 | ContextCompat.checkSelfPermission( 14 | application, 15 | permission.toAndroidId() 16 | ) == PackageManager.PERMISSION_GRANTED 17 | } 18 | 19 | private fun PermissionChecker.Permission.toAndroidId() = when (this) { 20 | PermissionChecker.Permission.COARSE_LOCATION -> Manifest.permission.ACCESS_COARSE_LOCATION 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/PlayServicesLocationDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Application 5 | import android.location.Geocoder 6 | import android.location.Location 7 | import com.devexperto.architectcoders.data.datasource.LocationDataSource 8 | import com.devexperto.architectcoders.ui.common.getFromLocationCompat 9 | import com.google.android.gms.location.LocationServices 10 | import kotlinx.coroutines.suspendCancellableCoroutine 11 | import javax.inject.Inject 12 | import kotlin.coroutines.resume 13 | 14 | class PlayServicesLocationDataSource @Inject constructor(application: Application) : LocationDataSource { 15 | private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(application) 16 | private val geocoder = Geocoder(application) 17 | 18 | override suspend fun findLastRegion(): String? = findLastLocation().toRegion() 19 | 20 | @SuppressLint("MissingPermission") 21 | private suspend fun findLastLocation(): Location = 22 | suspendCancellableCoroutine { continuation -> 23 | fusedLocationClient.lastLocation 24 | .addOnCompleteListener { 25 | continuation.resume(it.result) 26 | } 27 | } 28 | 29 | private suspend fun Location?.toRegion(): String? { 30 | val addresses = this?.let { 31 | geocoder.getFromLocationCompat(latitude, longitude, 1) 32 | } 33 | return addresses?.firstOrNull()?.countryCode 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/database/Movie.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.database 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity 7 | data class Movie( 8 | @PrimaryKey(autoGenerate = true) val id: Int, 9 | val title: String, 10 | val overview: String, 11 | val releaseDate: String, 12 | val posterPath: String, 13 | val backdropPath: String, 14 | val originalLanguage: String, 15 | val originalTitle: String, 16 | val popularity: Double, 17 | val voteAverage: Double, 18 | val favorite: Boolean 19 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/database/MovieDao.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.database 2 | 3 | import androidx.room.* 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | @Dao 7 | interface MovieDao { 8 | 9 | @Query("SELECT * FROM Movie") 10 | fun getAll(): Flow> 11 | 12 | @Query("SELECT * FROM Movie WHERE id = :id") 13 | fun findById(id: Int): Flow 14 | 15 | @Query("SELECT COUNT(id) FROM Movie") 16 | suspend fun movieCount(): Int 17 | 18 | @Insert(onConflict = OnConflictStrategy.REPLACE) 19 | suspend fun insertMovies(movies: List) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/database/MovieDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | 6 | @Database(entities = [Movie::class], version = 1, exportSchema = false) 7 | abstract class MovieDatabase : RoomDatabase() { 8 | 9 | abstract fun movieDao(): MovieDao 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/database/MovieRoomDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.database 2 | 3 | import com.devexperto.architectcoders.data.datasource.MovieLocalDataSource 4 | import com.devexperto.architectcoders.data.tryCall 5 | import com.devexperto.architectcoders.domain.Error 6 | import com.devexperto.architectcoders.domain.Movie 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.map 9 | import javax.inject.Inject 10 | import com.devexperto.architectcoders.data.database.Movie as DbMovie 11 | 12 | class MovieRoomDataSource @Inject constructor(private val movieDao: MovieDao) : MovieLocalDataSource { 13 | 14 | override val movies: Flow> = movieDao.getAll().map { it.toDomainModel() } 15 | 16 | override suspend fun isEmpty(): Boolean = movieDao.movieCount() == 0 17 | 18 | override fun findById(id: Int): Flow = movieDao.findById(id).map { it.toDomainModel() } 19 | 20 | override suspend fun save(movies: List): Error? = tryCall { 21 | movieDao.insertMovies(movies.fromDomainModel()) 22 | }.fold( 23 | ifLeft = { it }, 24 | ifRight = { null } 25 | ) 26 | } 27 | 28 | private fun List.toDomainModel(): List = map { it.toDomainModel() } 29 | 30 | private fun DbMovie.toDomainModel(): Movie = 31 | Movie( 32 | id, 33 | title, 34 | overview, 35 | releaseDate, 36 | posterPath, 37 | backdropPath, 38 | originalLanguage, 39 | originalTitle, 40 | popularity, 41 | voteAverage, 42 | favorite 43 | ) 44 | 45 | private fun List.fromDomainModel(): List = map { it.fromDomainModel() } 46 | 47 | private fun Movie.fromDomainModel(): DbMovie = DbMovie( 48 | id, 49 | title, 50 | overview, 51 | releaseDate, 52 | posterPath, 53 | backdropPath, 54 | originalLanguage, 55 | originalTitle, 56 | popularity, 57 | voteAverage, 58 | favorite 59 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/extensions.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import com.devexperto.architectcoders.domain.Error 7 | import retrofit2.HttpException 8 | import java.io.IOException 9 | 10 | fun Throwable.toError(): Error = when (this) { 11 | is IOException -> Error.Connectivity 12 | is HttpException -> Error.Server(code()) 13 | else -> Error.Unknown(message ?: "") 14 | } 15 | 16 | suspend fun tryCall(action: suspend () -> T): Either = try { 17 | action().right() 18 | } catch (e: Exception) { 19 | e.toError().left() 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/server/MovieServerDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.server 2 | 3 | import arrow.core.Either 4 | import com.devexperto.architectcoders.data.datasource.MovieRemoteDataSource 5 | import com.devexperto.architectcoders.data.tryCall 6 | import com.devexperto.architectcoders.di.ApiKey 7 | import com.devexperto.architectcoders.domain.Error 8 | import com.devexperto.architectcoders.domain.Movie 9 | import javax.inject.Inject 10 | 11 | class MovieServerDataSource @Inject constructor( 12 | @ApiKey private val apiKey: String, 13 | private val remoteService: RemoteService 14 | ) : 15 | MovieRemoteDataSource { 16 | 17 | override suspend fun findPopularMovies(region: String): Either> = tryCall { 18 | remoteService 19 | .listPopularMovies(apiKey, region) 20 | .results 21 | .toDomainModel() 22 | } 23 | } 24 | 25 | private fun List.toDomainModel(): List = map { it.toDomainModel() } 26 | 27 | private fun RemoteMovie.toDomainModel(): Movie = 28 | Movie( 29 | id, 30 | title, 31 | overview, 32 | releaseDate, 33 | "https://image.tmdb.org/t/p/w185/$posterPath", 34 | backdropPath?.let { "https://image.tmdb.org/t/p/w780/$it" } ?: "", 35 | originalLanguage, 36 | originalTitle, 37 | popularity, 38 | voteAverage, 39 | false 40 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/server/RemoteResult.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.server 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class RemoteResult( 6 | val page: Int, 7 | val results: List, 8 | @SerializedName("total_pages") val totalPages: Int, 9 | @SerializedName("total_results") val totalResults: Int 10 | ) 11 | 12 | data class RemoteMovie( 13 | val adult: Boolean, 14 | @SerializedName("backdrop_path") val backdropPath: String?, 15 | @SerializedName("genre_ids") val genreIds: List, 16 | val id: Int, 17 | @SerializedName("original_language") val originalLanguage: String, 18 | @SerializedName("original_title") val originalTitle: String, 19 | val overview: String, 20 | val popularity: Double, 21 | @SerializedName("poster_path") val posterPath: String, 22 | @SerializedName("release_date") val releaseDate: String, 23 | val title: String, 24 | val video: Boolean, 25 | @SerializedName("vote_average") val voteAverage: Double, 26 | @SerializedName("vote_count") val voteCount: Int 27 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/data/server/RemoteService.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.data.server 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Query 5 | 6 | interface RemoteService { 7 | 8 | @GET("discover/movie?sort_by=popularity.desc") 9 | suspend fun listPopularMovies( 10 | @Query("api_key") apiKey: String, 11 | @Query("region") region: String 12 | ): RemoteResult 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/di/ApiKey.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Retention(AnnotationRetention.BINARY) 6 | @Qualifier 7 | annotation class ApiKey -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/di/ApiUrl.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Retention(AnnotationRetention.BINARY) 6 | @Qualifier 7 | annotation class ApiUrl -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/di/AppModule.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.di 2 | 3 | import android.app.Application 4 | import androidx.room.Room 5 | import com.devexperto.architectcoders.R 6 | import com.devexperto.architectcoders.data.AndroidPermissionChecker 7 | import com.devexperto.architectcoders.data.PermissionChecker 8 | import com.devexperto.architectcoders.data.PlayServicesLocationDataSource 9 | import com.devexperto.architectcoders.data.database.MovieDatabase 10 | import com.devexperto.architectcoders.data.database.MovieRoomDataSource 11 | import com.devexperto.architectcoders.data.datasource.LocationDataSource 12 | import com.devexperto.architectcoders.data.datasource.MovieLocalDataSource 13 | import com.devexperto.architectcoders.data.datasource.MovieRemoteDataSource 14 | import com.devexperto.architectcoders.data.server.MovieServerDataSource 15 | import com.devexperto.architectcoders.data.server.RemoteService 16 | import dagger.Binds 17 | import dagger.Module 18 | import dagger.Provides 19 | import dagger.hilt.InstallIn 20 | import dagger.hilt.components.SingletonComponent 21 | import okhttp3.OkHttpClient 22 | import okhttp3.logging.HttpLoggingInterceptor 23 | import retrofit2.Retrofit 24 | import retrofit2.converter.gson.GsonConverterFactory 25 | import retrofit2.create 26 | import javax.inject.Singleton 27 | 28 | @Module 29 | @InstallIn(SingletonComponent::class) 30 | object AppModule { 31 | 32 | @Provides 33 | @Singleton 34 | @ApiKey 35 | fun provideApiKey(app: Application): String = app.getString(R.string.api_key) 36 | 37 | @Provides 38 | @Singleton 39 | fun provideDatabase(app: Application) = Room.databaseBuilder( 40 | app, 41 | MovieDatabase::class.java, 42 | "movie-db" 43 | ).build() 44 | 45 | @Provides 46 | @Singleton 47 | fun provideMovieDao(db: MovieDatabase) = db.movieDao() 48 | 49 | @Provides 50 | @Singleton 51 | @ApiUrl 52 | fun provideApiUrl(): String = "https://api.themoviedb.org/3/" 53 | 54 | @Provides 55 | @Singleton 56 | fun provideOkHttpClient(): OkHttpClient = HttpLoggingInterceptor().run { 57 | level = HttpLoggingInterceptor.Level.BODY 58 | OkHttpClient.Builder().addInterceptor(this).build() 59 | } 60 | 61 | @Provides 62 | @Singleton 63 | fun provideRemoteService(@ApiUrl apiUrl: String, okHttpClient: OkHttpClient): RemoteService { 64 | 65 | return Retrofit.Builder() 66 | .baseUrl(apiUrl) 67 | .client(okHttpClient) 68 | .addConverterFactory(GsonConverterFactory.create()) 69 | .build() 70 | .create() 71 | } 72 | 73 | } 74 | 75 | @Module 76 | @InstallIn(SingletonComponent::class) 77 | abstract class AppDataModule { 78 | 79 | @Binds 80 | abstract fun bindLocalDataSource(localDataSource: MovieRoomDataSource): MovieLocalDataSource 81 | 82 | @Binds 83 | abstract fun bindRemoteDataSource(remoteDataSource: MovieServerDataSource): MovieRemoteDataSource 84 | 85 | @Binds 86 | abstract fun bindLocationDataSource(locationDataSource: PlayServicesLocationDataSource): LocationDataSource 87 | 88 | @Binds 89 | abstract fun bindPermissionChecker(permissionChecker: AndroidPermissionChecker): PermissionChecker 90 | 91 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/di/MovieId.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Retention(AnnotationRetention.BINARY) 6 | @Qualifier 7 | annotation class MovieId -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/NavHostActivity.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.devexperto.architectcoders.R 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | @AndroidEntryPoint 9 | class NavHostActivity : AppCompatActivity() { 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | setContentView(R.layout.activity_nav_host) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/common/AspectRatioImageView.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.common 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import androidx.appcompat.widget.AppCompatImageView 6 | import com.devexperto.architectcoders.R 7 | 8 | class AspectRatioImageView @JvmOverloads constructor( 9 | context: Context, 10 | attrs: AttributeSet? = null, 11 | defStyleAttr: Int = 0 12 | ) : AppCompatImageView(context, attrs, defStyleAttr) { 13 | 14 | private var ratio: Float = DEFAULT_RATIO 15 | 16 | init { 17 | attrs?.let { 18 | val a = context.obtainStyledAttributes(attrs, R.styleable.AspectRatioImageView) 19 | with(a) { 20 | ratio = getFloat(R.styleable.AspectRatioImageView_ratio, DEFAULT_RATIO) 21 | recycle() 22 | } 23 | } 24 | } 25 | 26 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 27 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 28 | var width = measuredWidth 29 | var height = measuredHeight 30 | 31 | if (width == 0 && height == 0) { 32 | return 33 | } 34 | 35 | if (width > 0) { 36 | height = (width * ratio).toInt() 37 | } else { 38 | width = (height / ratio).toInt() 39 | } 40 | 41 | setMeasuredDimension(width, height) 42 | } 43 | 44 | companion object { 45 | const val DEFAULT_RATIO = 1F 46 | } 47 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/common/BindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.common 2 | 3 | import android.view.View 4 | import android.widget.ImageView 5 | import androidx.databinding.BindingAdapter 6 | 7 | @BindingAdapter("url") 8 | fun ImageView.bindUrl(url: String?) { 9 | if (url != null) loadUrl(url) 10 | } 11 | 12 | @BindingAdapter("visible") 13 | fun View.setVisible(visible: Boolean?) { 14 | visibility = if (visible == true) View.VISIBLE else View.GONE 15 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/common/PermissionRequester.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.common 2 | 3 | import androidx.activity.result.contract.ActivityResultContracts 4 | import androidx.fragment.app.Fragment 5 | import kotlinx.coroutines.suspendCancellableCoroutine 6 | import kotlin.coroutines.resume 7 | 8 | class PermissionRequester(fragment: Fragment, private val permission: String) { 9 | 10 | private var onRequest: (Boolean) -> Unit = {} 11 | private val launcher = 12 | fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> 13 | onRequest(isGranted) 14 | } 15 | 16 | suspend fun request(): Boolean = 17 | suspendCancellableCoroutine { continuation -> 18 | onRequest = { 19 | continuation.resume(it) 20 | } 21 | launcher.launch(permission) 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/common/extensions.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.common 2 | 3 | import android.content.Context 4 | import android.location.Address 5 | import android.location.Geocoder 6 | import android.os.Build 7 | import android.view.LayoutInflater 8 | import android.view.View 9 | import android.view.ViewGroup 10 | import android.widget.ImageView 11 | import androidx.annotation.FloatRange 12 | import androidx.annotation.IntRange 13 | import androidx.annotation.LayoutRes 14 | import androidx.fragment.app.Fragment 15 | import androidx.recyclerview.widget.DiffUtil 16 | import androidx.lifecycle.Lifecycle 17 | import androidx.lifecycle.LifecycleOwner 18 | import androidx.lifecycle.lifecycleScope 19 | import androidx.lifecycle.repeatOnLifecycle 20 | import com.bumptech.glide.Glide 21 | import com.devexperto.architectcoders.App 22 | import kotlinx.coroutines.flow.Flow 23 | import kotlinx.coroutines.launch 24 | import kotlinx.coroutines.suspendCancellableCoroutine 25 | import kotlin.coroutines.resume 26 | 27 | fun ViewGroup.inflate(@LayoutRes layoutRes: Int, attachToRoot: Boolean = true): View = 28 | LayoutInflater.from(context).inflate(layoutRes, this, attachToRoot) 29 | 30 | fun ImageView.loadUrl(url: String) { 31 | Glide.with(context).load(url).into(this) 32 | } 33 | 34 | inline fun basicDiffUtil( 35 | crossinline areItemsTheSame: (T, T) -> Boolean = { old, new -> old == new }, 36 | crossinline areContentsTheSame: (T, T) -> Boolean = { old, new -> old == new } 37 | ) = object : DiffUtil.ItemCallback() { 38 | override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = 39 | areItemsTheSame(oldItem, newItem) 40 | 41 | override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = 42 | areContentsTheSame(oldItem, newItem) 43 | } 44 | 45 | @Suppress("DEPRECATION") 46 | suspend fun Geocoder.getFromLocationCompat( 47 | @FloatRange(from = -90.0, to = 90.0) latitude: Double, 48 | @FloatRange(from = -180.0, to = 180.0) longitude: Double, 49 | @IntRange maxResults: Int 50 | ): List
= suspendCancellableCoroutine { continuation -> 51 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 52 | getFromLocation(latitude, longitude, maxResults) { 53 | continuation.resume(it) 54 | } 55 | } else { 56 | continuation.resume(getFromLocation(latitude, longitude, maxResults) ?: emptyList()) 57 | } 58 | } 59 | 60 | fun LifecycleOwner.launchAndCollect( 61 | flow: Flow, 62 | state: Lifecycle.State = Lifecycle.State.STARTED, 63 | body: (T) -> Unit 64 | ) { 65 | lifecycleScope.launch { 66 | this@launchAndCollect.repeatOnLifecycle(state) { 67 | flow.collect(body) 68 | } 69 | } 70 | } 71 | 72 | val Context.app: App get() = applicationContext as App 73 | 74 | val Fragment.app: App get() = requireContext().app -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/detail/DetailBindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.detail 2 | 3 | import androidx.databinding.BindingAdapter 4 | import com.devexperto.architectcoders.domain.Movie 5 | 6 | @BindingAdapter("movie") 7 | fun MovieDetailInfoView.updateMovieDetails(movie: Movie?) { 8 | if (movie != null) { 9 | setMovie(movie) 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/detail/DetailFragment.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.detail 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.fragment.app.viewModels 7 | import com.devexperto.architectcoders.R 8 | import com.devexperto.architectcoders.databinding.FragmentDetailBinding 9 | import com.devexperto.architectcoders.ui.common.launchAndCollect 10 | import dagger.hilt.android.AndroidEntryPoint 11 | 12 | @AndroidEntryPoint 13 | class DetailFragment : Fragment(R.layout.fragment_detail) { 14 | 15 | private val viewModel: DetailViewModel by viewModels() 16 | 17 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 18 | super.onViewCreated(view, savedInstanceState) 19 | val binding = FragmentDetailBinding.bind(view) 20 | 21 | binding.movieDetailToolbar.setNavigationOnClickListener { 22 | requireActivity().onBackPressedDispatcher.onBackPressed() 23 | } 24 | binding.movieDetailFavorite.setOnClickListener { viewModel.onFavoriteClicked() } 25 | 26 | viewLifecycleOwner.launchAndCollect(viewModel.state) { state -> 27 | if (state.movie != null) { 28 | binding.movie = state.movie 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/detail/DetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.detail 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.devexperto.architectcoders.data.toError 6 | import com.devexperto.architectcoders.di.MovieId 7 | import com.devexperto.architectcoders.domain.Error 8 | import com.devexperto.architectcoders.domain.Movie 9 | import com.devexperto.architectcoders.usecases.FindMovieUseCase 10 | import com.devexperto.architectcoders.usecases.SwitchMovieFavoriteUseCase 11 | import dagger.hilt.android.lifecycle.HiltViewModel 12 | import kotlinx.coroutines.flow.MutableStateFlow 13 | import kotlinx.coroutines.flow.StateFlow 14 | import kotlinx.coroutines.flow.asStateFlow 15 | import kotlinx.coroutines.flow.catch 16 | import kotlinx.coroutines.flow.update 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | 20 | 21 | @HiltViewModel 22 | class DetailViewModel @Inject constructor( 23 | @MovieId private val movieId: Int, 24 | findMovieUseCase: FindMovieUseCase, 25 | private val switchMovieFavoriteUseCase: SwitchMovieFavoriteUseCase 26 | ) : ViewModel() { 27 | 28 | private val _state = MutableStateFlow(UiState()) 29 | val state: StateFlow = _state.asStateFlow() 30 | 31 | init { 32 | viewModelScope.launch { 33 | findMovieUseCase(movieId) 34 | .catch { cause -> _state.update { it.copy(error = cause.toError()) } } 35 | .collect { movie -> _state.update { UiState(movie = movie) } } 36 | } 37 | } 38 | 39 | fun onFavoriteClicked() { 40 | viewModelScope.launch { 41 | _state.value.movie?.let { movie -> 42 | val error = switchMovieFavoriteUseCase(movie) 43 | _state.update { it.copy(error = error) } 44 | } 45 | } 46 | } 47 | 48 | data class UiState(val movie: Movie? = null, val error: Error? = null) 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/detail/MovieDetailInfoView.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.detail 2 | 3 | import android.content.Context 4 | import android.util.AttributeSet 5 | import androidx.appcompat.widget.AppCompatTextView 6 | import androidx.core.text.bold 7 | import androidx.core.text.buildSpannedString 8 | import com.devexperto.architectcoders.domain.Movie 9 | 10 | class MovieDetailInfoView @JvmOverloads constructor( 11 | context: Context, 12 | attrs: AttributeSet? = null, 13 | defStyleAttr: Int = 0 14 | ) : AppCompatTextView(context, attrs, defStyleAttr) { 15 | 16 | fun setMovie(movie: Movie) = movie.apply { 17 | text = buildSpannedString { 18 | 19 | bold { append("Original language: ") } 20 | appendLine(originalLanguage) 21 | 22 | bold { append("Original title: ") } 23 | appendLine(originalTitle) 24 | 25 | bold { append("Release date: ") } 26 | appendLine(releaseDate) 27 | 28 | bold { append("Popularity: ") } 29 | appendLine(popularity.toString()) 30 | 31 | bold { append("Vote Average: ") } 32 | append(voteAverage.toString()) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/detail/di.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.detail 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import com.devexperto.architectcoders.di.MovieId 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.android.components.ViewModelComponent 9 | import dagger.hilt.android.scopes.ViewModelScoped 10 | 11 | @Module 12 | @InstallIn(ViewModelComponent::class) 13 | class DetailViewModelModule { 14 | 15 | @Provides 16 | @ViewModelScoped 17 | @MovieId 18 | fun provideMovieId(savedStateHandle: SavedStateHandle) = 19 | DetailFragmentArgs.fromSavedStateHandle(savedStateHandle).movieId 20 | 21 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/main/MainBindingAdapters.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.main 2 | 3 | import androidx.databinding.BindingAdapter 4 | import androidx.recyclerview.widget.RecyclerView 5 | import com.devexperto.architectcoders.domain.Movie 6 | 7 | @BindingAdapter("items") 8 | fun RecyclerView.setItems(movies: List?) { 9 | if (movies != null) { 10 | (adapter as? MoviesAdapter)?.submitList(movies) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/main/MainFragment.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.main 2 | 3 | import android.os.Bundle 4 | import android.view.View 5 | import androidx.fragment.app.Fragment 6 | import androidx.fragment.app.viewModels 7 | import com.devexperto.architectcoders.R 8 | import com.devexperto.architectcoders.databinding.FragmentMainBinding 9 | import com.devexperto.architectcoders.ui.common.launchAndCollect 10 | import dagger.hilt.android.AndroidEntryPoint 11 | 12 | @AndroidEntryPoint 13 | class MainFragment : Fragment(R.layout.fragment_main) { 14 | 15 | private val viewModel: MainViewModel by viewModels() 16 | 17 | private lateinit var mainState: MainState 18 | 19 | private val adapter = MoviesAdapter { mainState.onMovieClicked(it) } 20 | 21 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 22 | super.onViewCreated(view, savedInstanceState) 23 | 24 | mainState = buildMainState() 25 | 26 | val binding = FragmentMainBinding.bind(view).apply { 27 | recycler.adapter = adapter 28 | } 29 | 30 | viewLifecycleOwner.launchAndCollect(viewModel.state) { 31 | binding.loading = it.loading 32 | binding.movies = it.movies 33 | binding.error = it.error?.let(mainState::errorToString) 34 | } 35 | 36 | mainState.requestLocationPermission { 37 | viewModel.onUiReady() 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/main/MainState.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.main 2 | 3 | import android.Manifest 4 | import android.content.Context 5 | import androidx.fragment.app.Fragment 6 | import androidx.lifecycle.lifecycleScope 7 | import androidx.navigation.NavController 8 | import androidx.navigation.fragment.findNavController 9 | import com.devexperto.architectcoders.R 10 | import com.devexperto.architectcoders.domain.Error 11 | import com.devexperto.architectcoders.domain.Movie 12 | import com.devexperto.architectcoders.ui.common.PermissionRequester 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.launch 15 | 16 | fun Fragment.buildMainState( 17 | context: Context = requireContext(), 18 | scope: CoroutineScope = viewLifecycleOwner.lifecycleScope, 19 | navController: NavController = findNavController(), 20 | locationPermissionRequester: PermissionRequester = PermissionRequester( 21 | this, 22 | Manifest.permission.ACCESS_COARSE_LOCATION 23 | ) 24 | ) = MainState(context, scope, navController, locationPermissionRequester) 25 | 26 | class MainState( 27 | private val context: Context, 28 | private val scope: CoroutineScope, 29 | private val navController: NavController, 30 | private val locationPermissionRequester: PermissionRequester 31 | ) { 32 | fun onMovieClicked(movie: Movie) { 33 | val action = MainFragmentDirections.actionMainToDetail(movie.id) 34 | navController.navigate(action) 35 | } 36 | 37 | fun requestLocationPermission(afterRequest: (Boolean) -> Unit) { 38 | scope.launch { 39 | val result = locationPermissionRequester.request() 40 | afterRequest(result) 41 | } 42 | } 43 | 44 | fun errorToString(error: Error) = when (error) { 45 | Error.Connectivity -> context.getString(R.string.connectivity_error) 46 | is Error.Server -> context.getString(R.string.server_error) + error.code 47 | is Error.Unknown -> context.getString(R.string.unknown_error) + error.message 48 | } 49 | 50 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/main/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.main 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.devexperto.architectcoders.data.toError 6 | import com.devexperto.architectcoders.domain.Error 7 | import com.devexperto.architectcoders.domain.Movie 8 | import com.devexperto.architectcoders.usecases.GetPopularMoviesUseCase 9 | import com.devexperto.architectcoders.usecases.RequestPopularMoviesUseCase 10 | import dagger.hilt.android.lifecycle.HiltViewModel 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.asStateFlow 14 | import kotlinx.coroutines.flow.catch 15 | import kotlinx.coroutines.flow.update 16 | import kotlinx.coroutines.launch 17 | import javax.inject.Inject 18 | 19 | @HiltViewModel 20 | class MainViewModel @Inject constructor( 21 | getPopularMoviesUseCase: GetPopularMoviesUseCase, 22 | private val requestPopularMoviesUseCase: RequestPopularMoviesUseCase 23 | ) : ViewModel() { 24 | 25 | private val _state = MutableStateFlow(UiState()) 26 | val state: StateFlow = _state.asStateFlow() 27 | 28 | init { 29 | viewModelScope.launch { 30 | getPopularMoviesUseCase() 31 | .catch { cause -> _state.update { it.copy(error = cause.toError()) } } 32 | .collect { movies -> _state.update { UiState(movies = movies) } } 33 | } 34 | } 35 | 36 | fun onUiReady() { 37 | viewModelScope.launch { 38 | _state.value = _state.value.copy(loading = true) 39 | val error = requestPopularMoviesUseCase() 40 | _state.value = _state.value.copy(loading = false, error = error) 41 | } 42 | } 43 | 44 | data class UiState( 45 | val loading: Boolean = false, 46 | val movies: List? = null, 47 | val error: Error? = null 48 | ) 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/devexperto/architectcoders/ui/main/MoviesAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.devexperto.architectcoders.ui.main 2 | 3 | import android.view.View 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.ListAdapter 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.devexperto.architectcoders.R 8 | import com.devexperto.architectcoders.databinding.ViewMovieBinding 9 | import com.devexperto.architectcoders.domain.Movie 10 | import com.devexperto.architectcoders.ui.common.basicDiffUtil 11 | import com.devexperto.architectcoders.ui.common.inflate 12 | 13 | class MoviesAdapter(private val listener: (Movie) -> Unit) : 14 | ListAdapter(basicDiffUtil { old, new -> old.id == new.id }) { 15 | 16 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 17 | val view = parent.inflate(R.layout.view_movie, false) 18 | return ViewHolder(view) 19 | } 20 | 21 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 22 | val movie = getItem(position) 23 | holder.bind(movie) 24 | holder.itemView.setOnClickListener { listener(movie) } 25 | } 26 | 27 | class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { 28 | private val binding = ViewMovieBinding.bind(view) 29 | fun bind(movie: Movie) { 30 | binding.movie = movie 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_arrow_back.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favorite_off.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_favorite_on.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_nav_host.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_detail.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 18 | 19 | 25 | 26 | 32 | 33 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 58 | 59 | 64 | 65 | 73 | 74 | 84 | 85 | 86 | 87 | 88 | 89 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 19 | 20 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 43 | 44 | 45 | 46 | 55 | 56 | 63 | 64 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_movie.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 21 | 22 | 25 | 26 | 34 | 35 | 42 | 43 | 44 | 45 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devexpert-io/architect-coders-v2/08481dd7f3173e48002aa8407a466c7030ee7478/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 20 | 21 | 26 | 30 | 31 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/api_key.xml: -------------------------------------------------------------------------------- 1 | 2 | d30e1f350220f9aad6c4110df385d380 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | #E0E0E0 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Architect Coders 3 | Connectivity Error 4 | \"Server Error: \" 5 | \"Unknown Error: \" 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 23 | 24 |