├── 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 |
4 |
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 |
13 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
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 |
16 |
17 | - Details screen showing the details of the image on click on the image in the list screen
18 |
19 |
20 |
21 | Navigation between the screens has been done using the Jetpack Navigation library and the following is its nav graph:
22 |
23 |
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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
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 |
5 |
6 |
7 |
8 |
9 |
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 |
120 |
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 |
--------------------------------------------------------------------------------