├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── styles.xml │ │ │ │ └── strings.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── layout │ │ │ │ ├── popular_photos_item.xml │ │ │ │ ├── activity_main.xml │ │ │ │ ├── popular_photos_fragment.xml │ │ │ │ └── fragment_photo_details.xml │ │ │ ├── navigation │ │ │ │ └── nav_graph.xml │ │ │ ├── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ │ └── drawable │ │ │ │ └── ic_launcher_background.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── prasan │ │ │ │ └── kotlinmvvmhiltflowapp │ │ │ │ ├── presentation │ │ │ │ ├── MainApplication.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── PopularPhotosAdapter.kt │ │ │ │ ├── fragment │ │ │ │ │ ├── PhotoDetailsFragment.kt │ │ │ │ │ └── PopularPhotosFragment.kt │ │ │ │ └── MainViewModel.kt │ │ │ │ ├── data │ │ │ │ ├── datamodel │ │ │ │ │ ├── Large.kt │ │ │ │ │ ├── Small.kt │ │ │ │ │ ├── Tiny.kt │ │ │ │ │ ├── Default.kt │ │ │ │ │ ├── Filters.kt │ │ │ │ │ ├── Avatars.kt │ │ │ │ │ ├── Image.kt │ │ │ │ │ ├── PhotoDetails.kt │ │ │ │ │ ├── PhotoResponse.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── FillSwitch.kt │ │ │ │ │ └── Photo.kt │ │ │ │ ├── FHPRepository.kt │ │ │ │ ├── NetworkDataSource.kt │ │ │ │ └── network │ │ │ │ │ ├── RetrofitWebService.kt │ │ │ │ │ └── FiveHundredPixelsAPI.kt │ │ │ │ ├── contract │ │ │ │ ├── IUseCase.kt │ │ │ │ ├── IRemoteDataSource.kt │ │ │ │ ├── IWebService.kt │ │ │ │ └── IRepository.kt │ │ │ │ ├── domain │ │ │ │ └── GetPopularPhotosUseCase.kt │ │ │ │ ├── di │ │ │ │ └── HiltDependenciesModule.kt │ │ │ │ └── Utils.kt │ │ └── AndroidManifest.xml │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── prasan │ │ │ └── kotlinmvvmhiltflowapp │ │ │ └── ExampleInstrumentedTest.kt │ └── test │ │ └── java │ │ └── com │ │ └── prasan │ │ └── kotlinmvvmhiltflowapp │ │ ├── TestCoroutineRule.kt │ │ └── presentation │ │ └── viewmodel │ │ └── MainViewModelTest.kt ├── proguard-rules.pro └── build.gradle ├── .idea ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── markdown-navigator ├── compiler.xml ├── misc.xml ├── runConfigurations.xml ├── jarRepositories.xml ├── markdown-navigator.xml └── markdown-navigator-enh.xml ├── settings.gradle ├── List_Screen.png ├── Details_Screen.png ├── Navigation_Graph.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ ├── Documentation.md │ ├── Feature.md │ ├── Enhancement.md │ └── Bug.md ├── LICENSE ├── gradle.properties ├── .gitignore ├── gradlew.bat ├── README.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name = "KotlinMVVMHiltFlowApp" -------------------------------------------------------------------------------- /List_Screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/List_Screen.png -------------------------------------------------------------------------------- /Details_Screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/Details_Screen.png -------------------------------------------------------------------------------- /Navigation_Graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/Navigation_Graph.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prasannajeet/kotlin-mvvm-hilt-flow-app/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/markdown-navigator: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6200EE 4 | #3700B3 5 | #03DAC5 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/presentation/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class MainApplication : Application() -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Nov 17 13:43:33 EST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What is the purpose of this change? What does it change? 2 | 3 | 4 | ## Was the change discussed in an issue? 5 | fixes #??? 6 | 7 | 8 | ## How to test changes? 9 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Large.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Large( 8 | @Json(name = "https") 9 | val https: String 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Small.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Small( 8 | @Json(name = "https") 9 | val https: String 10 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Tiny.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Tiny( 8 | @Json(name = "https") 9 | val https: String 10 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Default.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Default( 8 | @Json(name = "https") 9 | val https: String 10 | ) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Report mistakes or request for documentation 4 | --- 5 | [kind/Documentation] 6 | 7 | 14 | 15 | ## What did you find missing in the documentation? 16 | 17 | 18 | ## What is the relevance of it? 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for the App 4 | --- 5 | [kind/Feature] 6 | 7 | 16 | 17 | ## Which functionality do you think we should add? 18 | 19 | 20 | ## Why is this needed? 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Filters.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Filters( 9 | @Json(name = "category") 10 | val category: Boolean?, 11 | @Json(name = "exclude") 12 | val exclude: Boolean? 13 | ) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement 3 | about: Suggest an enhancement for existing functionality 4 | --- 5 | [kind/Enhancement] 6 | 15 | 16 | ## Which functionality do you think we should update/improve? 17 | 18 | 19 | ## Why is this needed? 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem with the app to help us resolve it and improve 4 | --- 5 | [kind/bug] 6 | 7 | 16 | 17 | ## What did you run exactly? 18 | 19 | 20 | ## Actual behavior 21 | 22 | 23 | ## Expected behavior 24 | 25 | 26 | ## Any logs, error output, etc? 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Avatars.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Avatars( 8 | @Json(name = "default") 9 | val default: Default, 10 | @Json(name = "large") 11 | val large: Large, 12 | @Json(name = "small") 13 | val small: Small, 14 | @Json(name = "tiny") 15 | val tiny: Tiny 16 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Image.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class Image( 8 | @Json(name = "format") 9 | val format: String, 10 | @Json(name = "https_url") 11 | val httpsUrl: String, 12 | @Json(name = "size") 13 | val size: Int, 14 | @Json(name = "url") 15 | val url: String 16 | ) -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/PhotoDetails.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import android.os.Parcelable 4 | import kotlinx.android.parcel.Parcelize 5 | 6 | @Parcelize 7 | data class PhotoDetails( 8 | val url: String?, 9 | val description: String?, 10 | val votesCount: Int?, 11 | val comments: Int?, 12 | val pulse: Double?, 13 | val impressions: Int?, 14 | val photoTitle: String?, 15 | val userName: String?, 16 | val userPhotoUrl: String?, 17 | val exif: String?, 18 | val durationPosted: String? 19 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/PhotoResponse.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class PhotoResponse( 9 | @Json(name = "current_page") 10 | val currentPage: Int, 11 | @Json(name = "feature") 12 | val feature: String, 13 | @Json(name = "filters") 14 | val filters: Filters, 15 | @Json(name = "photos") 16 | val photos: List, 17 | @Json(name = "total_items") 18 | val totalItems: Int, 19 | @Json(name = "total_pages") 20 | val totalPages: Int 21 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/presentation/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.prasan.kotlinmvvmhiltflowapp.databinding.ActivityMainBinding 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | /** 9 | * This activity acts as the host for all fragments in the application 10 | * @author Prasan 11 | * @since 1.0 12 | */ 13 | @AndroidEntryPoint 14 | class MainActivity : AppCompatActivity() { 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | setContentView(ActivityMainBinding.inflate(layoutInflater).root) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/popular_photos_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Kotlin MVVM Hilt Flow App 3 | Photo from popular section 4 | Description: 5 | Likes: 6 | Comments: 7 | Pulse: 8 | Viewed: 9 | Title: 10 | EXIF: 11 | Posted: 12 | Photographer: 13 | -------------------------------------------------------------------------------- /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/java/com/prasan/kotlinmvvmhiltflowapp/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.prasan.a500pxcodingchallenge", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/contract/IUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.contract 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | /** 8 | * A UseCase defines a specific task performed in the app. For this project there would be two: 9 | * 1. Get Popular Photos 10 | * 2. Get details of a photo 11 | * [O] type defines the output of the use-case execution 12 | * @author Prasan 13 | */ 14 | interface IUseCase { 15 | 16 | val repository: IRepository 17 | 18 | /** 19 | * Execution contract which will run the business logic associated with completing a 20 | * particular use case 21 | * @param input [I] type input parameter 22 | * @since 1.0 23 | * @return [O] model type used to define the UseCase class 24 | */ 25 | @ExperimentalCoroutinesApi 26 | suspend fun execute(input: I): Flow> 27 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/FHPRepository.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.contract.IRemoteDataSource 5 | import com.prasan.kotlinmvvmhiltflowapp.contract.IRepository 6 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | /** 13 | * Repository class impl from [IRepository] 14 | * @author Prasan 15 | * @since 1.0 16 | * @see IRepository 17 | * @see IRemoteDataSource 18 | */ 19 | @Singleton 20 | class FHPRepository @Inject constructor(override val remoteDataSource: IRemoteDataSource) : 21 | IRepository { 22 | 23 | @ExperimentalCoroutinesApi 24 | override suspend fun getPhotosByPage(pageNumber: Int): Flow> = 25 | remoteDataSource.getPhotosByPage(pageNumber) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/NetworkDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.contract.IRemoteDataSource 5 | import com.prasan.kotlinmvvmhiltflowapp.contract.IWebService 6 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | /** 13 | * [IRemoteDataSource] impl class that provides access to network API calls 14 | * @author Prasan 15 | * @since 1.0 16 | * @see IRemoteDataSource 17 | * @see IWebService 18 | */ 19 | @Singleton 20 | class NetworkDataSource @Inject constructor(override val webService: IWebService) : 21 | IRemoteDataSource { 22 | 23 | @ExperimentalCoroutinesApi 24 | override suspend fun getPhotosByPage(pageNumber: Int): Flow> = 25 | webService.getPhotosByPage(pageNumber) 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/contract/IRemoteDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.contract 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | /** 9 | * Contract for the remote data source that will provide the plug to access network data by obtaining 10 | * an instance of [IWebService] interface in the implementing class 11 | * @author Prasan 12 | * @since 1.0 13 | * @see IWebService 14 | */ 15 | interface IRemoteDataSource { 16 | 17 | // Webservice Interface that a remote data source impl class needs to provide 18 | val webService: IWebService 19 | 20 | /** 21 | * Requests the webservice class to obtain a list of photos by page number 22 | * @param pageNumber Page Number 23 | * @return [Flow] of [IOTaskResult] of [PhotoResponse] type 24 | */ 25 | @ExperimentalCoroutinesApi 26 | suspend fun getPhotosByPage(pageNumber: Int): Flow> 27 | } -------------------------------------------------------------------------------- /app/src/test/java/com/prasan/kotlinmvvmhiltflowapp/TestCoroutineRule.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp 2 | 3 | import kotlinx.coroutines.Dispatchers 4 | import kotlinx.coroutines.ExperimentalCoroutinesApi 5 | import kotlinx.coroutines.test.TestCoroutineDispatcher 6 | import kotlinx.coroutines.test.resetMain 7 | import kotlinx.coroutines.test.setMain 8 | import org.junit.rules.TestWatcher 9 | import org.junit.runner.Description 10 | 11 | /** 12 | * This rule is used to create a test-only coroutine dispatcher in order to circumvent the Main 13 | * dispatcher that is Android Specific 14 | */ 15 | @ExperimentalCoroutinesApi 16 | class TestCoroutineRule(private val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : 17 | TestWatcher() { 18 | override fun starting(description: Description?) { 19 | super.starting(description) 20 | Dispatchers.setMain(testDispatcher) 21 | } 22 | 23 | override fun finished(description: Description?) { 24 | super.finished(description) 25 | Dispatchers.resetMain() 26 | testDispatcher.cleanupTestCoroutines() 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/domain/GetPopularPhotosUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.domain 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.contract.IRepository 5 | import com.prasan.kotlinmvvmhiltflowapp.contract.IUseCase 6 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | /** 13 | * [IUseCase] class implementation that retrieves a paginated list of photos from the service 14 | * Takes a page number as input and returns the [IOTaskResult] [PhotoResponse] instance in return 15 | * @author Prasan 16 | * @since 1.0 17 | */ 18 | @Singleton 19 | class GetPopularPhotosUseCase @Inject constructor(override val repository: IRepository) : 20 | IUseCase { 21 | 22 | @ExperimentalCoroutinesApi 23 | override suspend fun execute(input: Int): Flow> = 24 | repository.getPhotosByPage(input) 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Prasannajeet Pani 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /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 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 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/contract/IWebService.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.contract 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | /** 9 | * This interface provides contracts a web-service class needs to abide by to provide the app 10 | * with network data as required 11 | * @author Prasan 12 | * @since 1.0 13 | */ 14 | interface IWebService { 15 | 16 | /** 17 | * Performs the popular photos API call. In an offline-first architecture, it is at this function 18 | * call that the Repository class would check if the data for the given page number exists in a Room 19 | * table, if so return the data from the db, else perform a retrofit call to obtain and store the data 20 | * into the db before returning the same 21 | * @param pageNumber Page number of the data called in a paginated data source 22 | * @return [IOTaskResult] of [PhotoResponse] type 23 | */ 24 | @ExperimentalCoroutinesApi 25 | suspend fun getPhotosByPage( 26 | pageNumber: Int 27 | ): Flow> 28 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/popular_photos_fragment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 21 | 22 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/network/RetrofitWebService.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.network 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.contract.IWebService 5 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 6 | import com.prasan.kotlinmvvmhiltflowapp.performSafeNetworkApiCall 7 | import kotlinx.coroutines.ExperimentalCoroutinesApi 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | import javax.inject.Singleton 11 | 12 | /** 13 | * [IWebService] impl class which uses Retrofit to provide the app with the functionality to make 14 | * network requests 15 | * @author Prasan 16 | * @since 1.0 17 | * @see FiveHundredPixelsAPI 18 | * @see [IWebService] 19 | */ 20 | @Singleton 21 | class RetrofitWebService @Inject constructor(private val retrofitClient: FiveHundredPixelsAPI) : 22 | IWebService { 23 | 24 | @ExperimentalCoroutinesApi 25 | override suspend fun getPhotosByPage( 26 | pageNumber: Int 27 | ): Flow> = 28 | 29 | performSafeNetworkApiCall("Error Obtaining Photos") { 30 | retrofitClient.getPopularPhotos( 31 | page = pageNumber 32 | ) 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/contract/IRepository.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.contract 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 4 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | /** 9 | * The repository class represents the data store of the application. This class is primarily utilised 10 | * when building offline-first applications where it will make the determination to load the data from 11 | * a local Room DB vs calling the retrofit function in order to obtain the data 12 | * @author Prasan 13 | * @since 1.0 14 | * @see IRemoteDataSource 15 | */ 16 | interface IRepository { 17 | 18 | val remoteDataSource: IRemoteDataSource 19 | 20 | /** 21 | * Makes the popular photos API call via data source. In an offline-first architecture, it is at this function 22 | * call that the Repository class would check if the data for the given page number exists in a Room 23 | * table, if so return the data from the db, else perform a retrofit call to obtain and store the data 24 | * into the db before returning the same 25 | * @param pageNumber Page number of the data called in a paginated data source 26 | */ 27 | @ExperimentalCoroutinesApi 28 | suspend fun getPhotosByPage(pageNumber: Int): Flow> 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/network/FiveHundredPixelsAPI.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.network 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.BuildConfig 4 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 5 | import retrofit2.Response 6 | import retrofit2.http.GET 7 | import retrofit2.http.Query 8 | import javax.inject.Singleton 9 | 10 | /** 11 | * Retrofit API class for the 500px API 12 | * @author Prasan 13 | * @since 1.0 14 | */ 15 | @Singleton 16 | interface FiveHundredPixelsAPI { 17 | 18 | /** 19 | * Performs a GET call to obtain a paginated list of photos 20 | * @param key API Key 21 | * @param feature feature source the photos should come from 22 | * @param page Page number of the data where the photos should come from 23 | * @return [Response] instance of [PhotoResponse] type 24 | */ 25 | @GET("/v1/photos?image_size=5,6") 26 | suspend fun getPopularPhotos( 27 | @Query("consumer_key") key: String = BuildConfig.API_KEY, 28 | @Query("feature") feature: String = "popular", 29 | @Query("page") page: Int 30 | ): Response 31 | 32 | //NOTE: As per https://github.com/500px/legacy-api-documentation/blob/master/basics/formats_and_terms.md#image-urls-and-image-sizes 33 | // image_size=5,6 should return a array of Images with variable sizes, but in the response only 1 size is being obtained 34 | 35 | } -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 16 | 19 | 20 | 21 | 22 | 27 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/User.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class User( 9 | @Json(name = "about") 10 | val about: String?, 11 | @Json(name = "active") 12 | val active: Int, 13 | @Json(name = "affection") 14 | val affection: Int, 15 | @Json(name = "avatar_version") 16 | val avatarVersion: Int, 17 | @Json(name = "avatars") 18 | val avatars: Avatars, 19 | @Json(name = "city") 20 | val city: String?, 21 | @Json(name = "country") 22 | val country: String?, 23 | @Json(name = "cover_url") 24 | val coverUrl: String?, 25 | @Json(name = "firstname") 26 | val firstname: String, 27 | @Json(name = "followers_count") 28 | val followersCount: Int, 29 | @Json(name = "following") 30 | val following: Boolean, 31 | @Json(name = "fullname") 32 | val fullname: String, 33 | @Json(name = "id") 34 | val id: Int, 35 | @Json(name = "lastname") 36 | val lastname: String?, 37 | @Json(name = "registration_date") 38 | val registrationDate: String, 39 | @Json(name = "state") 40 | val state: String?, 41 | @Json(name = "upgrade_status") 42 | val upgradeStatus: Int, 43 | @Json(name = "username") 44 | val username: String, 45 | @Json(name = "userpic_https_url") 46 | val userpicHttpsUrl: String, 47 | @Json(name = "userpic_url") 48 | val userpicUrl: String, 49 | @Json(name = "usertype") 50 | val usertype: Int 51 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/FillSwitch.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | import com.squareup.moshi.Json 4 | import com.squareup.moshi.JsonClass 5 | 6 | @JsonClass(generateAdapter = true) 7 | data class FillSwitch( 8 | @Json(name = "access_deleted") 9 | val accessDeleted: Boolean, 10 | @Json(name = "access_private") 11 | val accessPrivate: Boolean, 12 | @Json(name = "always_exclude_nude") 13 | val alwaysExcludeNude: Boolean, 14 | @Json(name = "current_user_id") 15 | val currentUserId: Any?, 16 | @Json(name = "exclude_block") 17 | val excludeBlock: Boolean, 18 | @Json(name = "exclude_nude") 19 | val excludeNude: Boolean, 20 | @Json(name = "exclude_private") 21 | val excludePrivate: Boolean, 22 | @Json(name = "include_admin_locks") 23 | val includeAdminLocks: Boolean, 24 | @Json(name = "include_comments") 25 | val includeComments: Boolean, 26 | @Json(name = "include_deleted") 27 | val includeDeleted: Boolean, 28 | @Json(name = "include_equipment_info") 29 | val includeEquipmentInfo: Boolean, 30 | @Json(name = "include_follow_info") 31 | val includeFollowInfo: Boolean, 32 | @Json(name = "include_geo") 33 | val includeGeo: Boolean, 34 | @Json(name = "include_licensing") 35 | val includeLicensing: Boolean, 36 | @Json(name = "include_like_by") 37 | val includeLikeBy: Boolean, 38 | @Json(name = "include_tags") 39 | val includeTags: Boolean, 40 | @Json(name = "include_user_info") 41 | val includeUserInfo: Boolean, 42 | @Json(name = "only_user_active") 43 | val onlyUserActive: Boolean 44 | ) -------------------------------------------------------------------------------- /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/prasan/kotlinmvvmhiltflowapp/presentation/PopularPhotosAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.prasan.kotlinmvvmhiltflowapp.ListItemClickListener 7 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.Photo 8 | import com.prasan.kotlinmvvmhiltflowapp.databinding.PopularPhotosItemBinding 9 | import com.prasan.kotlinmvvmhiltflowapp.loadUrl 10 | import kotlin.properties.Delegates 11 | 12 | /** 13 | * Adapter class for list of popular photos 14 | * @author Prasan 15 | * 16 | */ 17 | class PopularPhotosAdapter( 18 | private val itemClickListener: ListItemClickListener 19 | ) : 20 | RecyclerView.Adapter() { 21 | 22 | var itemList: List by Delegates.observable(emptyList()) { _, _, _ -> 23 | notifyDataSetChanged() 24 | } 25 | 26 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PopularPhotosViewHolder = 27 | PopularPhotosViewHolder(PopularPhotosItemBinding.inflate(LayoutInflater.from(parent.context))) 28 | 29 | 30 | override fun onBindViewHolder(holder: PopularPhotosViewHolder, position: Int) { 31 | 32 | val photo = itemList[position] 33 | holder.bind(photo) 34 | holder.itemView.setOnClickListener { itemClickListener(photo) } 35 | } 36 | 37 | override fun getItemCount() = itemList.size 38 | 39 | /** 40 | * ViewHolder class for [PopularPhotosAdapter] 41 | * @author Prasan 42 | * @since 1.0 43 | */ 44 | class PopularPhotosViewHolder(private val binding: PopularPhotosItemBinding) : 45 | RecyclerView.ViewHolder(binding.root) { 46 | 47 | fun bind(photo: Photo) { 48 | binding.popularPhoto.loadUrl(photo.images[0].httpsUrl) 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/presentation/fragment/PhotoDetailsFragment.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation.fragment 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.activityViewModels 9 | import androidx.lifecycle.Observer 10 | import com.prasan.kotlinmvvmhiltflowapp.ViewState 11 | import com.prasan.kotlinmvvmhiltflowapp.databinding.FragmentPhotoDetailsBinding 12 | import com.prasan.kotlinmvvmhiltflowapp.presentation.MainViewModel 13 | import com.prasan.kotlinmvvmhiltflowapp.showToast 14 | 15 | /** 16 | * [Fragment] displays details of the photo tapped on in [PopularPhotosFragment] 17 | * @author Prasan 18 | * @since 1.0 19 | * @see [MainViewModel] 20 | */ 21 | class PhotoDetailsFragment : Fragment() { 22 | 23 | private val viewModel: MainViewModel by activityViewModels() 24 | private lateinit var binding: FragmentPhotoDetailsBinding 25 | 26 | override fun onCreateView( 27 | inflater: LayoutInflater, container: ViewGroup?, 28 | savedInstanceState: Bundle? 29 | ): View? { 30 | binding = FragmentPhotoDetailsBinding.inflate(inflater) 31 | retainInstance = true 32 | return binding.root 33 | } 34 | 35 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 36 | 37 | arguments?.let { 38 | viewModel.processPhotoDetailsArgument(it) 39 | .observe(viewLifecycleOwner, Observer { uiState -> 40 | when (uiState) { 41 | is ViewState.RenderSuccess -> 42 | binding.photoDetails = uiState.output 43 | is ViewState.RenderFailure -> 44 | context?.showToast(uiState.throwable.message!!) 45 | } 46 | }) 47 | } ?: context?.showToast("Invalid data") 48 | } 49 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.aar 4 | *.ap_ 5 | *.aab 6 | 7 | # Files for the ART/Dalvik VM 8 | *.dex 9 | 10 | # Java class files 11 | *.class 12 | 13 | # Generated files 14 | bin/ 15 | gen/ 16 | out/ 17 | # Uncomment the following line in case you need and you don't have the release build type files in your app 18 | # release/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea/workspace.xml 42 | .idea/tasks.xml 43 | .idea/gradle.xml 44 | .idea/assetWizardSettings.xml 45 | .idea/dictionaries 46 | .idea/libraries 47 | # Android Studio 3 in .gitignore file. 48 | .idea/caches 49 | .idea/modules.xml 50 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 51 | .idea/navEditor.xml 52 | 53 | # Keystore files 54 | # Uncomment the following lines if you do not want to check your keystore files in. 55 | #*.jks 56 | #*.keystore 57 | 58 | # External native build folder generated in Android Studio 2.2 and later 59 | .externalNativeBuild 60 | .cxx/ 61 | 62 | # Google Services (e.g. APIs or Firebase) 63 | # google-services.json 64 | 65 | # Freeline 66 | freeline.py 67 | freeline/ 68 | freeline_project_description.json 69 | 70 | # fastlane 71 | fastlane/report.xml 72 | fastlane/Preview.html 73 | fastlane/screenshots 74 | fastlane/test_output 75 | fastlane/readme.md 76 | 77 | # Version control 78 | vcs.xml 79 | 80 | # lint 81 | lint/intermediates/ 82 | lint/generated/ 83 | lint/outputs/ 84 | lint/tmp/ 85 | # lint/reports/ 86 | 87 | .gradle 88 | /local.properties 89 | /.idea/caches 90 | /.idea/libraries 91 | /.idea/modules.xml 92 | /.idea/workspace.xml 93 | /.idea/navEditor.xml 94 | /.idea/sonarlint/ 95 | /.idea/assetWizardSettings.xml 96 | .DS_Store 97 | /build 98 | /captures 99 | config 100 | .cxx 101 | 102 | /.idea/.name 103 | /.idea/detekt.xml 104 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kotlin MVVM app using clean architecture, Jetpack, Hilt, Retrofit and Coroutines Flow API 2 | 3 | ## Introduction 4 | This application is a simple implementation of the popular images feature of 500px which using the [500px API](https://github.com/500px/legacy-api-documentation) built using modern Android development strategies focusing on the following key aspects: 5 | - Code structuring as per clean Architecture 6 | - Using MVVM Pattern as per Google's recommendation 7 | - Android Architecture Components (LiveData, ViewModel, Navigation) 8 | - Kotlin features (Lambdas, Extension functions, typealias, sealed class and Coroutines) 9 | 10 | ## App Overview 11 | The app features a 2 screen navigation 12 | 13 | - List screen displaying popular images in a paginated fashion 14 | 15 | Popular Images List 16 | 17 | - Details screen showing the details of the image on click on the image in the list screen 18 | 19 | Image Details 20 | 21 | Navigation between the screens has been done using the Jetpack Navigation library and the following is its nav graph: 22 | 23 | Nav Graph 24 | 25 | ## Libraries The App uses libraries and tools used to build Modern Android application, mainly part of Android Jetpack 26 | - [Kotlin](https://kotlinlang.org/) first 27 | - [Coroutines Flow API](https://kotlinlang.org/docs/reference/coroutines/flow.html) 28 | - [Android Architecture Components](https://developer.android.com/topic/libraries/architecture) 29 | - [Android desugaring for Java 8+ APIs](https://developer.android.com/studio/write/java8-support#library-desugaring) 30 | - [Retrofit](https://square.github.io/retrofit/) 31 | - [Moshi](https://github.com/square/moshi) 32 | - [Picasso](https://square.github.io/picasso/) 33 | - [Hilt](https://dagger.dev/hilt/) for dependency injection 34 | - [Android KTX](https://developer.android.com/kotlin/ktx) features 35 | - [MockK](https://mockk.io/) for unit testing 36 | 37 | ### Scope for Improvements 38 | The app can be further improved with the addition of the following features 39 | - Dynamic image sizes using multiple ViewHolders for different image sizes instead of current GridLayoutManager implementation 40 | - Espresso Tests -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/data/datamodel/Photo.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.data.datamodel 2 | 3 | 4 | import com.squareup.moshi.Json 5 | import com.squareup.moshi.JsonClass 6 | 7 | @JsonClass(generateAdapter = true) 8 | data class Photo( 9 | @Json(name = "aperture") 10 | val aperture: String?, 11 | @Json(name = "camera") 12 | val camera: String?, 13 | @Json(name = "category") 14 | val category: Int?, 15 | @Json(name = "comments_count") 16 | val commentsCount: Int?, 17 | @Json(name = "created_at") 18 | val createdAt: String?, 19 | @Json(name = "description") 20 | val description: String?, 21 | @Json(name = "editored_by") 22 | val editoredBy: Any?, 23 | @Json(name = "editors_choice") 24 | val editorsChoice: Boolean?, 25 | @Json(name = "editors_choice_date") 26 | val editorsChoiceDate: Any?, 27 | @Json(name = "feature") 28 | val feature: String?, 29 | @Json(name = "feature_date") 30 | val featureDate: String?, 31 | @Json(name = "fill_switch") 32 | val fillSwitch: FillSwitch, 33 | @Json(name = "focal_length") 34 | val focalLength: String?, 35 | @Json(name = "has_nsfw_tags") 36 | val hasNsfwTags: Boolean, 37 | @Json(name = "height") 38 | val height: Int, 39 | @Json(name = "highest_rating") 40 | val highestRating: Double?, 41 | @Json(name = "highest_rating_date") 42 | val highestRatingDate: String?, 43 | @Json(name = "id") 44 | val id: Int?, 45 | @Json(name = "image_format") 46 | val imageFormat: String?, 47 | @Json(name = "image_url") 48 | val imageUrl: List?, 49 | @Json(name = "images") 50 | val images: List, 51 | @Json(name = "iso") 52 | val iso: String?, 53 | @Json(name = "latitude") 54 | val latitude: Double?, 55 | @Json(name = "lens") 56 | val lens: String?, 57 | @Json(name = "liked") 58 | val liked: Any?, 59 | @Json(name = "location") 60 | val location: String?, 61 | @Json(name = "longitude") 62 | val longitude: Double?, 63 | @Json(name = "name") 64 | val name: String?, 65 | @Json(name = "nsfw") 66 | val nsfw: Boolean?, 67 | @Json(name = "positive_votes_count") 68 | val positiveVotesCount: Int?, 69 | @Json(name = "privacy") 70 | val privacy: Boolean?, 71 | @Json(name = "privacy_level") 72 | val privacyLevel: Int?, 73 | @Json(name = "profile") 74 | val profile: Boolean?, 75 | @Json(name = "rating") 76 | val rating: Double?, 77 | @Json(name = "shutter_speed") 78 | val shutterSpeed: String?, 79 | @Json(name = "status") 80 | val status: Int?, 81 | @Json(name = "taken_at") 82 | val takenAt: Any?, 83 | @Json(name = "times_viewed") 84 | val timesViewed: Int?, 85 | @Json(name = "url") 86 | val url: String?, 87 | @Json(name = "user") 88 | val user: User?, 89 | @Json(name = "user_id") 90 | val userId: Int?, 91 | @Json(name = "voted") 92 | val voted: Any?, 93 | @Json(name = "votes_count") 94 | val votesCount: Int?, 95 | @Json(name = "watermark") 96 | val watermark: Boolean?, 97 | @Json(name = "width") 98 | val width: Int? 99 | ) -------------------------------------------------------------------------------- /.idea/markdown-navigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 31 | 32 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | xmlns:android 17 | 18 | ^$ 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | xmlns:.* 28 | 29 | ^$ 30 | 31 | 32 | BY_NAME 33 | 34 |
35 |
36 | 37 | 38 | 39 | .*:id 40 | 41 | http://schemas.android.com/apk/res/android 42 | 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 | .*:name 51 | 52 | http://schemas.android.com/apk/res/android 53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 | 61 | name 62 | 63 | ^$ 64 | 65 | 66 | 67 |
68 |
69 | 70 | 71 | 72 | style 73 | 74 | ^$ 75 | 76 | 77 | 78 |
79 |
80 | 81 | 82 | 83 | .* 84 | 85 | ^$ 86 | 87 | 88 | BY_NAME 89 | 90 |
91 |
92 | 93 | 94 | 95 | .* 96 | 97 | http://schemas.android.com/apk/res/android 98 | 99 | 100 | ANDROID_ATTRIBUTE_ORDER 101 | 102 |
103 |
104 | 105 | 106 | 107 | .* 108 | 109 | .* 110 | 111 | 112 | BY_NAME 113 | 114 |
115 |
116 |
117 |
118 | 119 | 121 |
122 |
-------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/di/HiltDependenciesModule.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.di 2 | 3 | import com.prasan.kotlinmvvmhiltflowapp.BuildConfig 4 | import com.prasan.kotlinmvvmhiltflowapp.contract.IRemoteDataSource 5 | import com.prasan.kotlinmvvmhiltflowapp.contract.IRepository 6 | import com.prasan.kotlinmvvmhiltflowapp.contract.IWebService 7 | import com.prasan.kotlinmvvmhiltflowapp.data.FHPRepository 8 | import com.prasan.kotlinmvvmhiltflowapp.data.NetworkDataSource 9 | import com.prasan.kotlinmvvmhiltflowapp.data.network.FiveHundredPixelsAPI 10 | import com.prasan.kotlinmvvmhiltflowapp.data.network.RetrofitWebService 11 | import com.prasan.kotlinmvvmhiltflowapp.domain.GetPopularPhotosUseCase 12 | import dagger.Module 13 | import dagger.Provides 14 | import dagger.hilt.InstallIn 15 | import dagger.hilt.android.components.ActivityComponent 16 | import okhttp3.OkHttpClient 17 | import okhttp3.logging.HttpLoggingInterceptor 18 | import retrofit2.Retrofit 19 | import retrofit2.converter.moshi.MoshiConverterFactory 20 | 21 | /** 22 | * Hilt Module class that builds our dependency graph 23 | * @author Prasan 24 | * @since 1.0 25 | */ 26 | @InstallIn(ActivityComponent::class) 27 | @Module 28 | object HiltDependenciesModule { 29 | 30 | /** 31 | * Returns the [HttpLoggingInterceptor] instance with logging level set to body 32 | * @since 1.0.0 33 | */ 34 | @Provides 35 | fun provideLoggingInterceptor() = HttpLoggingInterceptor().apply { 36 | level = HttpLoggingInterceptor.Level.BODY 37 | } 38 | 39 | /** 40 | * Provides an [OkHttpClient] 41 | * @param loggingInterceptor [HttpLoggingInterceptor] instance 42 | * @since 1.0.0 43 | */ 44 | @Provides 45 | fun provideOKHttpClient(loggingInterceptor: HttpLoggingInterceptor) = OkHttpClient().apply { 46 | OkHttpClient.Builder().run { 47 | addInterceptor(loggingInterceptor) 48 | build() 49 | } 50 | } 51 | 52 | /** 53 | * Returns a [MoshiConverterFactory] instance 54 | * @since 1.0.0 55 | */ 56 | @Provides 57 | fun provideMoshiConverterFactory(): MoshiConverterFactory = MoshiConverterFactory.create() 58 | 59 | /** 60 | * Returns an instance of the [FiveHundredPixelsAPI] interface for the retrofit class 61 | * @return [FiveHundredPixelsAPI] impl 62 | * @since 1.0.0 63 | */ 64 | @Provides 65 | fun provideRetrofitInstance( 66 | client: OkHttpClient, 67 | moshiConverterFactory: MoshiConverterFactory 68 | ): FiveHundredPixelsAPI = 69 | Retrofit.Builder().run { 70 | baseUrl(BuildConfig.BASE_URL) 71 | addConverterFactory(moshiConverterFactory) 72 | client(client) 73 | build() 74 | }.run { 75 | create(FiveHundredPixelsAPI::class.java) 76 | } 77 | 78 | 79 | /** 80 | * Returns a [IWebService] impl 81 | * @param retrofitClient [FiveHundredPixelsAPI] retrofit interface 82 | * @since 1.0.0 83 | */ 84 | @Provides 85 | fun providesRetrofitService(retrofitClient: FiveHundredPixelsAPI): IWebService = 86 | RetrofitWebService(retrofitClient) 87 | 88 | /** 89 | * Returns a [IRemoteDataSource] impl 90 | * @param webService [IWebService] instance 91 | * @since 1.0.0 92 | */ 93 | @Provides 94 | fun providesNetworkDataSource(webService: IWebService): IRemoteDataSource = 95 | NetworkDataSource(webService) 96 | 97 | /** 98 | * Returns a singleton [IRepository] implementation 99 | * @param remoteDataSource [IRemoteDataSource] implementation 100 | * @since 1.0.0 101 | */ 102 | @Provides 103 | fun provideRepository(remoteDataSource: IRemoteDataSource): IRepository = 104 | FHPRepository(remoteDataSource) 105 | 106 | /** 107 | * Returns a [GetPopularPhotosUseCase] instance 108 | * @param repository [IRepository] impl 109 | * @since 1.0.0 110 | */ 111 | @Provides 112 | fun provideUseCase(repository: IRepository): GetPopularPhotosUseCase = 113 | GetPopularPhotosUseCase( 114 | repository 115 | ) 116 | } -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'dagger.hilt.android.plugin' 6 | apply plugin: "androidx.navigation.safeargs.kotlin" 7 | 8 | //def myConfigPropertiesFile = rootProject.file("config") 9 | //def myConfigProperties = new Properties() 10 | //myConfigProperties.load(new FileInputStream(myConfigPropertiesFile)) 11 | 12 | android { 13 | compileSdkVersion 30 14 | 15 | defaultConfig { 16 | applicationId "com.prasan.kotlinmvvmhiltflowapp" 17 | minSdkVersion 22 18 | targetSdkVersion 30 19 | versionCode 1 20 | versionName "1.0" 21 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 22 | } 23 | 24 | buildTypes { 25 | 26 | release { 27 | minifyEnabled false 28 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 29 | } 30 | 31 | debug { 32 | //buildConfigField("String", "API_KEY","\"${myConfigProperties['apiKey']}\"") 33 | buildConfigField("String", "API_KEY","\"\"") 34 | buildConfigField("String", "BASE_URL","\"https://api.500px.com/\"") 35 | } 36 | } 37 | 38 | buildFeatures { 39 | dataBinding true 40 | } 41 | 42 | compileOptions { 43 | coreLibraryDesugaringEnabled true 44 | sourceCompatibility JavaVersion.VERSION_1_8 45 | targetCompatibility JavaVersion.VERSION_1_8 46 | } 47 | 48 | // For Kotlin projects 49 | kotlinOptions { 50 | jvmTarget = "1.8" 51 | } 52 | androidExtensions { 53 | features = ["parcelize"] 54 | } 55 | } 56 | 57 | dependencies { 58 | implementation fileTree(dir: "libs", include: ["*.jar"]) 59 | 60 | // Kotlin specific dependencies 61 | implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.3.72' 62 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7' 63 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.5' 64 | 65 | // Core Android dependencies 66 | implementation 'androidx.core:core-ktx:1.3.2' 67 | implementation 'androidx.appcompat:appcompat:1.2.0' 68 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 69 | implementation 'androidx.navigation:navigation-fragment-ktx:2.3.1' 70 | implementation 'androidx.recyclerview:recyclerview:1.1.0' 71 | implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' 72 | 73 | // UI 74 | implementation 'de.hdodenhof:circleimageview:3.1.0' 75 | 76 | // Networking 77 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 78 | implementation 'com.squareup.retrofit2:converter-moshi:2.9.0' 79 | implementation 'com.squareup.moshi:moshi-kotlin:1.8.0' 80 | kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.8.0' 81 | implementation 'com.squareup.okhttp3:okhttp:4.7.2' 82 | implementation 'com.squareup.okhttp3:logging-interceptor:4.7.2' 83 | implementation ('com.squareup.picasso:picasso:2.71828'){ 84 | exclude group: "com.android.support" 85 | } 86 | 87 | // Android KTX 88 | implementation 'androidx.activity:activity-ktx:1.1.0' 89 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0' 90 | implementation 'androidx.fragment:fragment-ktx:1.2.5' 91 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0' 92 | implementation 'androidx.navigation:navigation-ui-ktx:2.3.1' 93 | implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0' 94 | 95 | // Test 96 | testImplementation 'junit:junit:4.12' 97 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 98 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 99 | testImplementation 'io.mockk:mockk:1.10.0' 100 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.7' 101 | testImplementation "androidx.arch.core:core-testing:2.1.0" 102 | 103 | coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' 104 | 105 | // Alphas 106 | implementation 'com.google.dagger:hilt-android:2.28.1-alpha' 107 | kapt 'com.google.dagger:hilt-android-compiler:2.28.1-alpha' 108 | implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha02' 109 | kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha02' 110 | } -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/presentation/fragment/PopularPhotosFragment.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation.fragment 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.activityViewModels 9 | import androidx.lifecycle.Observer 10 | import androidx.navigation.fragment.findNavController 11 | import androidx.recyclerview.widget.RecyclerView 12 | import com.prasan.kotlinmvvmhiltflowapp.* 13 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.Photo 14 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoDetails 15 | import com.prasan.kotlinmvvmhiltflowapp.databinding.PopularPhotosFragmentBinding 16 | import com.prasan.kotlinmvvmhiltflowapp.presentation.MainViewModel 17 | import com.prasan.kotlinmvvmhiltflowapp.presentation.PopularPhotosAdapter 18 | import kotlinx.coroutines.ExperimentalCoroutinesApi 19 | 20 | /** 21 | * This [Fragment] displays a list of popular photos from 500px in a paginated fashion, the next page of data 22 | * is loaded when the scrolling of the current list of items is at its end. On loading, the API calls the first 23 | * page of data. 24 | * @author Prasan 25 | * @since 1.0 26 | * @see [MainViewModel] 27 | */ 28 | class PopularPhotosFragment : Fragment() { 29 | 30 | private val viewModel: MainViewModel by activityViewModels() 31 | private lateinit var binding: PopularPhotosFragmentBinding 32 | private val photoItemClickListener: ListItemClickListener = { 33 | val photoDetails = PhotoDetails( 34 | it.images[0].httpsUrl, 35 | it.description, 36 | it.votesCount, 37 | it.commentsCount, 38 | it.rating, 39 | it.timesViewed, 40 | it.name, 41 | it.user?.fullname, 42 | it.user?.avatars?.default?.https, 43 | it.getFormattedExifData(), 44 | it.durationPosted() 45 | ) 46 | findNavController() 47 | .navigate( 48 | PopularPhotosFragmentDirections 49 | .actionPopularPhotosFragmentToPhotoDetailsFragment(photoDetails) 50 | ) 51 | viewModel.navigatingFromDetails = true 52 | } 53 | 54 | override fun onCreateView( 55 | inflater: LayoutInflater, container: ViewGroup?, 56 | savedInstanceState: Bundle? 57 | ): View? { 58 | binding = PopularPhotosFragmentBinding.inflate(inflater) 59 | retainInstance = true 60 | return binding.root 61 | } 62 | 63 | 64 | @ExperimentalCoroutinesApi 65 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 66 | 67 | super.onViewCreated(view, savedInstanceState) 68 | binding.popularPhotoList.addOnScrollListener(object : RecyclerView.OnScrollListener() { 69 | 70 | override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { 71 | 72 | super.onScrollStateChanged(recyclerView, newState) 73 | if (!recyclerView.canScrollVertically(1)) { 74 | viewModel.onRecyclerViewScrolledToBottom() 75 | } 76 | } 77 | }) 78 | 79 | viewModel.popularPhotosLiveData.observe(viewLifecycleOwner, Observer { viewState -> 80 | when (viewState) { 81 | 82 | is ViewState.Loading -> 83 | binding.loading.visibility = 84 | if (viewState.isLoading) View.VISIBLE else View.GONE 85 | 86 | is ViewState.RenderFailure -> 87 | viewState.throwable.message?.let { toastMessage -> 88 | context?.showToast(toastMessage) 89 | } 90 | 91 | is ViewState.RenderSuccess -> { 92 | 93 | if (binding.popularPhotoList.adapter == null) { 94 | binding.popularPhotoList.adapter = PopularPhotosAdapter( 95 | photoItemClickListener 96 | ) 97 | } 98 | 99 | (binding.popularPhotoList.adapter as PopularPhotosAdapter).itemList = 100 | viewState.output 101 | } 102 | } 103 | }) 104 | viewModel.getPhotosNextPage() 105 | } 106 | } -------------------------------------------------------------------------------- /.idea/markdown-navigator-enh.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/prasan/kotlinmvvmhiltflowapp/presentation/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation 2 | 3 | import android.os.Bundle 4 | import androidx.annotation.NonNull 5 | import androidx.hilt.lifecycle.ViewModelInject 6 | import androidx.lifecycle.MutableLiveData 7 | import androidx.lifecycle.ViewModel 8 | import androidx.lifecycle.asLiveData 9 | import androidx.lifecycle.viewModelScope 10 | import com.prasan.kotlinmvvmhiltflowapp.ViewState 11 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.Photo 12 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoDetails 13 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 14 | import com.prasan.kotlinmvvmhiltflowapp.domain.GetPopularPhotosUseCase 15 | import com.prasan.kotlinmvvmhiltflowapp.getViewStateFlowForNetworkCall 16 | import kotlinx.coroutines.ExperimentalCoroutinesApi 17 | import kotlinx.coroutines.flow.collect 18 | import kotlinx.coroutines.flow.flow 19 | import kotlinx.coroutines.launch 20 | 21 | /** 22 | * In an MVVM architecture, the [ViewModel] acts as the point at which the view and the data layers 23 | * of the applicable interface in order to implemented the business logic. This ViewModel contains 24 | * methods to show the list of popular photos in a paginated fashion as well as to parse the [Bundle] 25 | * arguments to show details of the page. In this app architecture, a single ViewModel is shared between 26 | * all fragments in the application 27 | * @author Prasan 28 | * @since 1.0 29 | */ 30 | class MainViewModel @ViewModelInject constructor( 31 | private val getPopularPhotosUseCase: GetPopularPhotosUseCase 32 | ) : ViewModel() { 33 | 34 | /** 35 | * [MutableLiveData] to notify the Popular photos list view with the list of photos 36 | */ 37 | val popularPhotosLiveData: MutableLiveData>> by lazy { 38 | MutableLiveData>>() 39 | } 40 | 41 | private var currentPageNumber = 1 // Page number currently displayed in UI 42 | private var maximumPageNumber = 2 // Total number of pages available in the paginated service 43 | private val photoList = 44 | ArrayList() // VM maintains the list of photos and adds new photos per page 45 | 46 | var navigatingFromDetails = 47 | false // This is a dirty implementation to avoid reloading the popular photo 48 | // fragment when back navigating from details. This is an issue with the Android Navigation Component that destroys 49 | // the fragment after navigation. Alternative implementation to store view in a variable will have memory leak 50 | // implications (https://twitter.com/ianhlake/status/1103522856535638016) 51 | 52 | /** 53 | * Retrieve the photos per page from the popular photos API and inform the view by [MutableLiveData] 54 | * @since 1.0 55 | */ 56 | @ExperimentalCoroutinesApi 57 | fun getPhotosNextPage() { 58 | 59 | if (navigatingFromDetails) { 60 | popularPhotosLiveData.value = ViewState.RenderSuccess(photoList) 61 | return 62 | } 63 | 64 | if (currentPageNumber < maximumPageNumber) { 65 | viewModelScope.launch { 66 | getViewStateFlowForNetworkCall { 67 | getPopularPhotosUseCase.execute(currentPageNumber) 68 | }.collect { 69 | when (it) { 70 | is ViewState.Loading -> popularPhotosLiveData.value = it 71 | is ViewState.RenderFailure -> popularPhotosLiveData.value = it 72 | is ViewState.RenderSuccess -> { 73 | currentPageNumber++ 74 | maximumPageNumber = it.output.totalPages 75 | photoList.addAll(it.output.photos) 76 | popularPhotosLiveData.value = ViewState.RenderSuccess(photoList) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | /** 85 | * Process the [Bundle] argument from the list fragment to process the photo details 86 | * @param args [Bundle] object containing parcelized [PhotoDetails] instance 87 | * @since 1.0 88 | */ 89 | fun processPhotoDetailsArgument(@NonNull args: Bundle) = 90 | 91 | flow { 92 | val photoDetails = args.getParcelable("photoDetails") 93 | 94 | photoDetails?.let { 95 | emit(ViewState.RenderSuccess(it)) 96 | } ?: run { 97 | emit(ViewState.RenderFailure(Exception("No Photo Details found"))) 98 | 99 | } 100 | }.asLiveData() 101 | 102 | /** 103 | * ViewModel function called by view when the list is scrolled to its bottommost position 104 | * in order to load the next page of data from the serve 105 | */ 106 | @ExperimentalCoroutinesApi 107 | fun onRecyclerViewScrolledToBottom() { 108 | if (navigatingFromDetails) navigatingFromDetails = false 109 | getPhotosNextPage() 110 | } 111 | } -------------------------------------------------------------------------------- /app/src/test/java/com/prasan/kotlinmvvmhiltflowapp/presentation/viewmodel/MainViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp.presentation.viewmodel 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.lifecycle.Observer 5 | import com.prasan.kotlinmvvmhiltflowapp.IOTaskResult 6 | import com.prasan.kotlinmvvmhiltflowapp.TestCoroutineRule 7 | import com.prasan.kotlinmvvmhiltflowapp.ViewState 8 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.Photo 9 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.PhotoResponse 10 | import com.prasan.kotlinmvvmhiltflowapp.domain.GetPopularPhotosUseCase 11 | import com.prasan.kotlinmvvmhiltflowapp.presentation.MainViewModel 12 | import io.mockk.MockKAnnotations 13 | import io.mockk.coEvery 14 | import io.mockk.every 15 | import io.mockk.impl.annotations.RelaxedMockK 16 | import io.mockk.verifyOrder 17 | import kotlinx.coroutines.ExperimentalCoroutinesApi 18 | import kotlinx.coroutines.flow.flow 19 | import kotlinx.coroutines.test.runBlockingTest 20 | import org.junit.Before 21 | import org.junit.Rule 22 | import org.junit.Test 23 | import org.mockito.ArgumentMatchers.anyList 24 | 25 | @ExperimentalCoroutinesApi 26 | class MainViewModelTest { 27 | 28 | // Set the main coroutines dispatcher for unit testing. 29 | @get:Rule 30 | var mainCoroutineRule = TestCoroutineRule() 31 | 32 | // Executes each task synchronously using Architecture Components. 33 | @get:Rule 34 | var instantExecutorRule = InstantTaskExecutorRule() 35 | 36 | private val fakeSuccessFlow = flow { 37 | emit(IOTaskResult.OnSuccess(mockPhotoResponse)) 38 | } 39 | 40 | private val fakeFailureFlow = flow { 41 | emit(IOTaskResult.OnFailed(mockException)) 42 | } 43 | 44 | @RelaxedMockK 45 | private lateinit var viewStateObserver: Observer>> 46 | 47 | @RelaxedMockK 48 | private lateinit var mockPhotoResponse: PhotoResponse 49 | 50 | @RelaxedMockK 51 | private lateinit var mockException: Exception 52 | 53 | @RelaxedMockK 54 | private lateinit var mockUseCase: GetPopularPhotosUseCase 55 | 56 | private val viewModel: MainViewModel by lazy { 57 | MainViewModel(mockUseCase) 58 | } 59 | 60 | @Before 61 | fun setup() { 62 | MockKAnnotations.init(this) 63 | every { mockException.message } returns "Test Exception" 64 | } 65 | 66 | @Test 67 | fun `load first page of Photos when getPopular photos is called for the first time`() { 68 | 69 | runBlockingTest { 70 | 71 | coEvery { mockUseCase.execute(1) } returns fakeSuccessFlow 72 | 73 | viewModel.popularPhotosLiveData.observeForever(viewStateObserver) 74 | viewModel.getPhotosNextPage() 75 | 76 | verifyOrder { 77 | viewStateObserver.onChanged(ViewState.Loading(true)) 78 | viewStateObserver.onChanged(ViewState.RenderSuccess(anyList())) 79 | viewStateObserver.onChanged(ViewState.Loading(false)) 80 | } 81 | } 82 | } 83 | 84 | @Test 85 | fun `when load photos service throws network failure then ViewState renders failure`() { 86 | 87 | runBlockingTest { 88 | 89 | coEvery { mockUseCase.execute(1) } returns fakeFailureFlow 90 | 91 | viewModel.popularPhotosLiveData.observeForever(viewStateObserver) 92 | viewModel.getPhotosNextPage() 93 | 94 | verifyOrder { 95 | viewStateObserver.onChanged(ViewState.Loading(true)) 96 | viewStateObserver.onChanged(ViewState.RenderFailure(mockException)) 97 | viewStateObserver.onChanged(ViewState.Loading(false)) 98 | } 99 | } 100 | } 101 | 102 | @Test 103 | fun `when list scrolled to the bottom then next page of photos is called`() { 104 | 105 | runBlockingTest { 106 | 107 | coEvery { mockUseCase.execute(1) } returns fakeSuccessFlow 108 | 109 | viewModel.popularPhotosLiveData.observeForever(viewStateObserver) 110 | viewModel.onRecyclerViewScrolledToBottom() 111 | 112 | verifyOrder { 113 | viewStateObserver.onChanged(ViewState.Loading(true)) 114 | viewStateObserver.onChanged(ViewState.RenderSuccess(anyList())) 115 | viewStateObserver.onChanged(ViewState.Loading(false)) 116 | } 117 | } 118 | } 119 | 120 | @Test 121 | fun `when list scrolled to the bottom and error in service call error propagated to view`() { 122 | 123 | runBlockingTest { 124 | 125 | coEvery { mockUseCase.execute(1) } returns fakeFailureFlow 126 | 127 | viewModel.popularPhotosLiveData.observeForever(viewStateObserver) 128 | viewModel.onRecyclerViewScrolledToBottom() 129 | 130 | verifyOrder { 131 | viewStateObserver.onChanged(ViewState.Loading(true)) 132 | viewStateObserver.onChanged(ViewState.RenderFailure(mockException)) 133 | viewStateObserver.onChanged(ViewState.Loading(false)) 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /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/java/com/prasan/kotlinmvvmhiltflowapp/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.prasan.kotlinmvvmhiltflowapp 2 | 3 | import android.content.Context 4 | import android.graphics.drawable.Drawable 5 | import android.widget.ImageView 6 | import android.widget.Toast 7 | import androidx.annotation.NonNull 8 | import androidx.databinding.BindingAdapter 9 | import com.prasan.kotlinmvvmhiltflowapp.data.datamodel.Photo 10 | import com.squareup.picasso.Picasso 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.ExperimentalCoroutinesApi 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.flow.* 15 | import retrofit2.Response 16 | import java.io.IOException 17 | import java.time.Duration 18 | import java.time.LocalDateTime 19 | import java.time.OffsetDateTime 20 | import java.time.format.DateTimeFormatter 21 | 22 | /** 23 | * Readable naming convention for Network call lambda 24 | * @since 1.0 25 | */ 26 | typealias NetworkAPIInvoke = suspend () -> Response 27 | 28 | /** 29 | * typealias for lambda passed when a photo is tapped on in Popular Photos Fragment 30 | */ 31 | typealias ListItemClickListener = (T) -> Unit 32 | 33 | /** 34 | * Sealed class type-restricts the result of IO calls to success and failure. The type 35 | * represents the model class expected from the API call in case of a success 36 | * In case of success, the result will be wrapped around the OnSuccessResponse class 37 | * In case of error, the throwable causing the error will be wrapped around OnErrorResponse class 38 | * @author Prasan 39 | * @since 1.0 40 | */ 41 | sealed class IOTaskResult { 42 | data class OnSuccess(val data: DTO) : IOTaskResult() 43 | data class OnFailed(val throwable: Throwable) : IOTaskResult() 44 | } 45 | 46 | /** 47 | * Utility function that works to perform a Retrofit API call and return either a success model 48 | * instance or an error message wrapped in an [Exception] class 49 | * @param messageInCaseOfError Custom error message to wrap around [IOTaskResult.OnFailed] 50 | * with a default value provided for flexibility 51 | * @param networkApiCall lambda representing a suspend function for the Retrofit API call 52 | * @return [IOTaskResult.OnSuccess] object of type [T], where [T] is the success object wrapped around 53 | * [IOTaskResult.OnSuccess] if network call is executed successfully, or [IOTaskResult.OnFailed] 54 | * object wrapping an [Exception] class stating the error 55 | * @since 1.0 56 | */ 57 | @ExperimentalCoroutinesApi 58 | suspend fun performSafeNetworkApiCall( 59 | messageInCaseOfError: String = "Network error", 60 | allowRetries: Boolean = true, 61 | numberOfRetries: Int = 2, 62 | networkApiCall: NetworkAPIInvoke 63 | ): Flow> { 64 | var delayDuration = 1000L 65 | val delayFactor = 2 66 | return flow { 67 | val response = networkApiCall() 68 | if (response.isSuccessful) { 69 | response.body()?.let { 70 | emit(IOTaskResult.OnSuccess(it)) 71 | } 72 | ?: emit(IOTaskResult.OnFailed(IOException("API call successful but empty response body"))) 73 | return@flow 74 | } 75 | emit( 76 | IOTaskResult.OnFailed( 77 | IOException( 78 | "API call failed with error - ${response.errorBody() 79 | ?.string() ?: messageInCaseOfError}" 80 | ) 81 | ) 82 | ) 83 | return@flow 84 | }.catch { e -> 85 | emit(IOTaskResult.OnFailed(IOException("Exception during network API call: ${e.message}"))) 86 | return@catch 87 | }.retryWhen { cause, attempt -> 88 | if (!allowRetries || attempt > numberOfRetries || cause !is IOException) return@retryWhen false 89 | delay(delayDuration) 90 | delayDuration *= delayFactor 91 | return@retryWhen true 92 | }.flowOn(Dispatchers.IO) 93 | } 94 | 95 | 96 | /** 97 | * [ImageView] extension function adds the capability to loading image by directly specifying 98 | * the url 99 | * @param url Image URL 100 | */ 101 | fun ImageView.loadUrl( 102 | @NonNull url: String, 103 | placeholder: Drawable = this.context.getDrawable(R.drawable.ic_launcher_foreground)!!, 104 | error: Drawable = this.context.getDrawable(R.drawable.ic_launcher_background)!! 105 | ) { 106 | Picasso.get() 107 | .load(url) 108 | .placeholder(placeholder) 109 | .error(error) 110 | .into(this) 111 | } 112 | 113 | /** 114 | * Alternate implementation to the above loadUrl method using data binding instead of extn functions 115 | * @param view [ImageView] to load the image via url 116 | * @param url URL of the image 117 | * @since 1.0 118 | */ 119 | @BindingAdapter("imageUrl") 120 | fun loadImage(view: ImageView, url: String) { 121 | view.loadUrl(url) 122 | } 123 | 124 | /** 125 | * Lets the UI act on a controlled bound of states that can be defined here 126 | * @author Prasan 127 | * @since 1.0 128 | */ 129 | sealed class ViewState { 130 | 131 | /** 132 | * Represents UI state where the UI should be showing a loading UX to the user 133 | * @param isLoading will be true when the loading UX needs to display, false when not 134 | */ 135 | data class Loading(val isLoading: Boolean) : ViewState() 136 | 137 | /** 138 | * Represents the UI state where the operation requested by the UI has been completed successfully 139 | * and the output of type [T] as asked by the UI has been provided to it 140 | * @param output result object of [T] type representing the fruit of the successful operation 141 | */ 142 | data class RenderSuccess(val output: T) : ViewState() 143 | 144 | /** 145 | * Represents the UI state where the operation requested by the UI has failed to complete 146 | * either due to a IO issue or a service exception and the same is conveyed back to the UI 147 | * to be shown the user 148 | * @param throwable [Throwable] instance containing the root cause of the failure in a [String] 149 | */ 150 | data class RenderFailure(val throwable: Throwable) : ViewState() 151 | } 152 | 153 | /** 154 | * Extension function on a fragment to show a toast message 155 | */ 156 | fun Context.showToast(@NonNull message: String) { 157 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 158 | } 159 | 160 | /** 161 | * Extension function on a [Photo] class that will convert the camera data into a single 162 | * string to be shown on the details screen 163 | */ 164 | fun Photo.getFormattedExifData() = StringBuilder().apply { 165 | 166 | append(if (camera != null && camera.isBlank()) "Unknown Camera" else camera) 167 | append(" + ") 168 | append(if (lens != null && lens.isBlank()) "Unknown Lens" else lens) 169 | append(" | ") 170 | append(if (focalLength != null && focalLength.isBlank()) "0mm" else focalLength + "mm") 171 | appendln() 172 | append(if (aperture != null && aperture.isBlank()) "f0" else "f/$aperture") 173 | append(" | ") 174 | append(if (shutterSpeed != null && shutterSpeed.isBlank()) "0s" else shutterSpeed + "s") 175 | append(" | ") 176 | append(if (iso != null && iso.isBlank()) "ISO0" else "ISO$iso") 177 | }.run { 178 | toString() 179 | } 180 | 181 | /** 182 | * Returns how long back does the created at date of the [Photo] object go 183 | * @since 1.0 184 | */ 185 | fun Photo.durationPosted(): String { 186 | 187 | val timeCreatedAt = 188 | OffsetDateTime.parse(createdAt, DateTimeFormatter.ISO_DATE_TIME).toLocalDateTime() 189 | val duration = Duration.between(timeCreatedAt, LocalDateTime.now()) 190 | 191 | return when { 192 | duration.toDays() == 1L -> { 193 | "${duration.toDays()} year" 194 | } 195 | duration.toDays() > 1 -> { 196 | "${duration.toDays()} years" 197 | } 198 | duration.toHours() == 1L -> { 199 | "${duration.toHours()} hour" 200 | } 201 | duration.toHours() > 1 -> { 202 | "${duration.toHours()} hours" 203 | } 204 | duration.toMinutes() == 1L -> { 205 | "${duration.toDays()} minute" 206 | } 207 | duration.toMinutes() > 1 -> { 208 | "${duration.toDays()} minutes" 209 | } 210 | else -> { 211 | "Less than a minute" 212 | } 213 | }.run { 214 | "$this ago" 215 | } 216 | } 217 | 218 | /** 219 | * Util method that takes a suspend function returning a [Flow] of [IOTaskResult] as input param and returns a 220 | * [Flow] of [ViewState], which emits [ViewState.Loading] with true prior to performing the IO Task. If the 221 | * IO operation results a [IOTaskResult.OnSuccess], the result is mapped to a [ViewState.RenderSuccess] instance and emitted, 222 | * else a [IOTaskResult.OnFailed] is mapped to a [ViewState.RenderFailure] instance and emitted. 223 | * The flowable is then completed by emitting a [ViewState.Loading] with false 224 | */ 225 | @ExperimentalCoroutinesApi 226 | suspend fun getViewStateFlowForNetworkCall(ioOperation: suspend () -> Flow>) = 227 | flow { 228 | emit(ViewState.Loading(true)) 229 | ioOperation().map { 230 | when (it) { 231 | is IOTaskResult.OnSuccess -> ViewState.RenderSuccess(it.data) 232 | is IOTaskResult.OnFailed -> ViewState.RenderFailure(it.throwable) 233 | } 234 | }.collect { 235 | emit(it) 236 | } 237 | emit(ViewState.Loading(false)) 238 | }.flowOn(Dispatchers.IO) -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_photo_details.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 17 | 18 | 22 | 23 | 31 | 32 | 38 | 39 | 46 | 47 | 54 | 55 | 56 | 57 | 63 | 64 | 71 | 72 | 79 | 80 | 81 | 82 | 88 | 89 | 96 | 97 | 104 | 105 | 106 | 107 | 113 | 114 | 121 | 122 | 129 | 130 | 131 | 132 | 138 | 139 | 146 | 147 | 154 | 155 | 156 | 157 | 163 | 164 | 171 | 172 | 179 | 180 | 181 | 182 | 188 | 189 | 196 | 197 | 204 | 205 | 206 | 207 | 213 | 214 | 221 | 222 | 229 | 230 | 231 | 232 | 238 | 239 | 246 | 247 | 248 | 256 | 257 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | --------------------------------------------------------------------------------