diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 00000000..547ba2b1
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 37a75096..7bfef59d 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/build.gradle b/app/build.gradle
index c31daba5..74b3df91 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,50 +1,144 @@
-apply plugin: 'com.android.application'
-
-apply plugin: 'kotlin-android'
-
-apply plugin: 'kotlin-android-extensions'
+apply plugin: Plugins.androidApplication
+apply plugin: Plugins.kotlinAndroid
+apply plugin: Plugins.kotlinAndroidExtensions
+apply plugin: Plugins.kotlinKapt
android {
- compileSdkVersion 29
- buildToolsVersion "29.0.1"
+ compileSdkVersion Configs.compileSdkVersion
+
defaultConfig {
- applicationId "com.mydigipay.challenge.github"
- minSdkVersion 17
- targetSdkVersion 29
- versionCode 1
- versionName "1.0"
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ applicationId Configs.applicationId
+ minSdkVersion Configs.minSdkVersion
+ targetSdkVersion Configs.targetSdkVersion
+ versionCode Configs.versionCode
+ versionName Configs.versionName
+
+ testInstrumentationRunner Configs.testInstrumentationRunner
+ multiDexEnabled true
}
+
buildTypes {
+ debug {
+ minifyEnabled false
+ shrinkResources false
+ //useProguard false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ applicationVariants.all { variant ->
+ variant.outputs.all {
+ outputFileName = "DigiPay Challenge v${defaultConfig.versionName} - (${variant.buildType.name}).apk"
+ }
+ }
+ }
release {
minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ shrinkResources false
+ //useProguard false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ applicationVariants.all { variant ->
+ variant.outputs.all {
+ outputFileName = "DigiPay Challenge v${defaultConfig.versionName}.apk"
+ }
+ }
}
}
+
+ /* This is how to use Java 8 Language Lambda feature. */
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ /* Specify JVM target of Kotlin to Java v1.8 */
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8
+ }
+
+ /* This is how to use data binding feature of Android Studio. */
+ dataBinding {
+ enabled = true
+ }
}
dependencies {
- implementation fileTree(dir: 'libs', include: ['*.jar'])
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
- implementation 'androidx.appcompat:appcompat:1.0.2'
- implementation 'androidx.core:core-ktx:1.0.2'
- implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
- implementation 'com.google.android.material:material:1.0.0'
- implementation 'com.squareup.retrofit2:retrofit:2.5.0'
- implementation 'com.squareup.okhttp3:okhttp:3.11.0'
- implementation 'org.koin:koin-android:2.0.1'
- implementation 'org.koin:koin-android-viewmodel:2.0.1'
-
-
- implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.50'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.1'
- implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.1'
- implementation 'com.squareup.okhttp3:logging-interceptor:3.11.0'
- implementation 'androidx.preference:preference-ktx:1.1.0'
- implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
- implementation 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2'
-
- testImplementation 'junit:junit:4.12'
- androidTestImplementation 'androidx.test:runner:1.1.1'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
+
+ /* Modules */
+ implementation project(path: ':core')
+
+ /* JAR Dependencies */
+ implementation fileTree(include: ['*.jar'], dir: 'libs')
+
+ /* Kotlin */
+ implementation Dependencies.kotlin
+
+ /* Testing */
+ testImplementation Dependencies.jUnit
+ androidTestImplementation Dependencies.androidXTestRunner
+ androidTestImplementation Dependencies.androidXTestExt
+ androidTestImplementation(Dependencies.espessoCore, {
+ exclude group: Dependencies.excludeGroup, module: Dependencies.excludeModule
+ })
+
+ /* AndroidX Support */
+ implementation Dependencies.androidXCoreKtx
+ implementation Dependencies.androidXAppCompat
+ implementation Dependencies.androidXConstraintLayout
+ implementation Dependencies.androidXRecyclerView
+ implementation Dependencies.androidXCardView
+ implementation Dependencies.androidXSwipeRefreshLayout
+ implementation Dependencies.googleAndroidMaterial
+
+ /* Jetpack Lifecycle Component */
+ implementation Dependencies.lifeCycleExtensions
+ kapt Dependencies.lifeCycleCommonJava8
+
+ /* Jetpack Room Component */
+ implementation Dependencies.roomRuntime
+ kapt Dependencies.roomCompiler
+ androidTestImplementation Dependencies.roomTesting
+ implementation Dependencies.roomRxJava
+
+ /* Jetpack Preference Component */
+ implementation Dependencies.preference
+
+ /* Jetpack Navigation Component */
+ implementation Dependencies.navigationFragment
+ implementation Dependencies.navigationFragmentKtx
+ implementation Dependencies.navigationUi
+ implementation Dependencies.navigationUiKtx
+ implementation Dependencies.navigationDynamicFeaturesFragment
+ androidTestImplementation Dependencies.navigationTesting
+
+ /* RxJava */
+ implementation Dependencies.rxJva
+ implementation Dependencies.rxAndroid
+
+ /* Kotlin Coroutines */
+ implementation Dependencies.kotlinXCoroutinesCore
+ implementation Dependencies.kotlinXCoroutinesAndroid
+
+ /* Dependency Injection using Dagger2 */
+ implementation Dependencies.dagger
+ implementation Dependencies.daggerAndroidSupport
+ kapt Dependencies.daggerCompiler
+ kapt Dependencies.daggerAndroidProcessor
+
+ /* Dependency Injection using Koin */
+ implementation Dependencies.koin
+ implementation Dependencies.koinViewModel
+
+ /* Remote API Call using Retrofit2 */
+ implementation Dependencies.retrofit
+ implementation Dependencies.converterGson
+ implementation Dependencies.adapterRxJava
+ implementation Dependencies.okHttp
+ implementation Dependencies.loggingInterceptor
+ implementation Dependencies.gson
+ implementation Dependencies.adapterCoroutines
+
+ /* Other */
+
+ implementation(Dependencies.glide) {
+ exclude group: Dependencies.excludeGroup
+ }
+ kapt Dependencies.glideCompiler
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 999179d8..debb660a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,35 +1,42 @@
-
+ package="com.mydigipay.challenge">
+
+
+
+ android:theme="@style/AppTheme">
+
+
-
-
-
-
+
+
+
-
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/mydigipay/challenge/App.kt b/app/src/main/java/com/mydigipay/challenge/App.kt
new file mode 100644
index 00000000..35d85ec9
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/App.kt
@@ -0,0 +1,33 @@
+package com.mydigipay.challenge
+
+import android.app.Activity
+import android.app.Application
+import com.mydigipay.challenge.framework.di.components.AppComponent
+import com.mydigipay.challenge.framework.di.components.DaggerAppComponent
+import dagger.android.AndroidInjector
+import dagger.android.DispatchingAndroidInjector
+import dagger.android.HasActivityInjector
+import javax.inject.Inject
+
+class App : Application(), HasActivityInjector {
+
+ @Inject
+ internal lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector
+
+ override fun activityInjector(): AndroidInjector {
+ return dispatchingAndroidInjector
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ mInjector = DaggerAppComponent.builder()
+ .app(this)
+ .build()
+ mInjector.inject(this)
+ }
+
+ companion object {
+ private lateinit var mInjector: AppComponent
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/Const.kt b/app/src/main/java/com/mydigipay/challenge/Const.kt
new file mode 100644
index 00000000..837ba262
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/Const.kt
@@ -0,0 +1,19 @@
+package com.mydigipay.challenge
+
+// The base URL used for request a user's GitHub identity.
+const val AUTHORIZE_URL: String = "https://github.com/login/oauth/authorize"
+
+// The base URL used for request AccessToken from GitHub.
+const val ACCESS_TOKEN_URL: String = "https://github.com/login/oauth/access_token/"
+
+// The base URL used for request AccessToken from GitHub.
+const val REST_API_URL: String = "https://api.github.com/"
+
+// The URL in your application where users are sent after authorization.
+const val REDIRECT_URI: String = "com.mydigipay.challenge://oauth2redirect"
+
+// The client ID you received from GitHub for your GitHub App.
+const val CLIENT_ID = "407ca6e82fb962149752"
+
+// The client secret you received from GitHub for your GitHub App.
+const val CLIENT_SECRET = "54026b111c06b89ea48bf5082ac8fce6da1480c5"
diff --git a/app/src/main/java/com/mydigipay/challenge/app/App.kt b/app/src/main/java/com/mydigipay/challenge/app/App.kt
deleted file mode 100644
index 65530767..00000000
--- a/app/src/main/java/com/mydigipay/challenge/app/App.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.mydigipay.challenge.app
-
-import android.app.Application
-import androidx.preference.PreferenceManager
-import com.mydigipay.challenge.network.di.accessTokenModule
-import com.mydigipay.challenge.network.di.networkModule
-import com.mydigipay.challenge.repository.token.TokenRepositoryImpl
-import org.koin.android.ext.koin.androidContext
-import org.koin.core.context.startKoin
-import org.koin.core.qualifier.named
-import org.koin.dsl.module
-const val APPLICATION_CONTEXT = "APPLICATION_CONTEXT"
-class App : Application() {
-
- override fun onCreate() {
- super.onCreate()
- startKoin {
- androidContext(this@App)
- modules(listOf(appModule, networkModule, accessTokenModule))
- }
- }
-
- val appModule = module {
- factory { TokenRepositoryImpl(get()) }
- single(named(APPLICATION_CONTEXT)) { applicationContext }
- single { PreferenceManager.getDefaultSharedPreferences(get()) }
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/mydigipay/challenge/common/BaseActivity.kt b/app/src/main/java/com/mydigipay/challenge/common/BaseActivity.kt
new file mode 100644
index 00000000..c2fc53a7
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/BaseActivity.kt
@@ -0,0 +1,225 @@
+package com.mydigipay.challenge.common
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.ContextMenu
+import android.view.ContextMenu.ContextMenuInfo
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import com.mydigipay.challenge.R
+import com.mydigipay.challenge.presentation.design.TAG
+import dagger.android.AndroidInjection
+import dagger.android.DispatchingAndroidInjector
+import dagger.android.support.HasSupportFragmentInjector
+import javax.inject.Inject
+
+abstract class BaseActivity : AppCompatActivity(), HasSupportFragmentInjector {
+
+ @Inject
+ lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector
+
+ override fun supportFragmentInjector() = dispatchingAndroidInjector
+
+ /****************************************************
+ * ACTIVITY LIFECYCLE
+ ***************************************************/
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ AndroidInjection.inject(this)
+ super.onCreate(savedInstanceState)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onCreate")
+
+ // FIRST, initialize the Activity with [savedInstanceState]
+ initializeActivity(savedInstanceState)
+
+ // Setup Views
+ setupViews()
+
+ // Set Action Bar
+ setupActionBar()
+
+ // Init fragments and then, open home fragment
+ setupNavigation()
+
+ // Setup Observers
+ setupObservers()
+
+ // LAST, extract required params from incoming Intent
+ extractIntentParams(intent)
+ }
+
+ override fun onStart() {
+ super.onStart()
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onStart")
+ }
+
+ override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+ super.onRestoreInstanceState(savedInstanceState)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onRestoreInstanceState")
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onResume")
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+
+ // DEBUG
+ Log.d(TAG, String.format("Lifecycle - onWindowFocusChanged(%s)", hasFocus))
+ }
+
+ override fun onBackPressed() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onBackPressed")
+ super.onBackPressed()
+ }
+
+ override fun onPause() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onPause")
+ super.onPause()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onSaveInstanceState")
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onStop() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onStop")
+ super.onStop()
+ }
+
+ override fun onRestart() {
+ super.onRestart()
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onRestart")
+ }
+
+ override fun onDestroy() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onDestroy")
+ super.onDestroy()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onNewIntent")
+ }
+
+ override fun onActivityResult(
+ requestCode: Int,
+ resultCode: Int,
+ data: Intent?
+ ) {
+ super.onActivityResult(requestCode, resultCode, data)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onActivityResult")
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ menuInflater.inflate(R.menu.menu_main, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ // Handle action bar item clicks here. The action bar will
+ // automatically handle clicks on the Home/Up button, so long
+ // as you specify a parent activity in AndroidManifest.xml.
+ when (item.itemId) {
+ R.id.action_settings -> {
+ // Nothing
+ return true
+ }
+ android.R.id.home -> {
+ // App icon in action bar clicked, so go home or finish this activity.
+ finish()
+ return true
+ }
+ }
+ return super.onOptionsItemSelected(item)
+ }
+
+ override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo) {
+ super.onCreateContextMenu(menu, v, menuInfo)
+ }
+
+ override fun onContextItemSelected(item: MenuItem): Boolean {
+ return true
+ }
+
+ /****************************************************
+ * ACTIVITY STATE
+ ***************************************************/
+
+ /**
+ * If [savedInstanceState] equals to null, initialize state from incoming Intent,
+ * else restore state from savedInstanceState.
+ */
+ protected abstract fun initializeActivity(savedInstanceState: Bundle?)
+
+ /**
+ * Extract data params from incoming Intent.
+ */
+ protected abstract fun extractIntentParams(data: Intent?)
+
+ /****************************************************
+ * VIEW/DATA BINDING
+ ***************************************************/
+
+ /**
+ * Set Content View,
+ * Set Action Bar,
+ * Init fragments and then, open HomeFragment as default,
+ * Bottom Navigation View,
+ * ...
+ */
+ protected abstract fun setupViews()
+ protected abstract fun setupActionBar()
+ protected abstract fun setupNavigation()
+
+ /****************************************************
+ * OBSERVERS
+ ***************************************************/
+
+ protected open fun setupObservers() {
+ // Nothing
+ }
+
+ /****************************************************
+ * SERVICE BINDING
+ ***************************************************/
+
+ // Nothing
+
+ /****************************************************
+ * FUNCTIONALITY
+ ***************************************************/
+
+ // Nothing
+
+ companion object {
+ val TAG = BaseActivity::class.java.simpleName
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/BaseFragment.kt b/app/src/main/java/com/mydigipay/challenge/common/BaseFragment.kt
new file mode 100644
index 00000000..7b6ad544
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/BaseFragment.kt
@@ -0,0 +1,189 @@
+package com.mydigipay.challenge.common
+
+import android.content.Context
+import android.os.Bundle
+import android.util.Log
+import android.view.*
+import android.view.ContextMenu.ContextMenuInfo
+import androidx.fragment.app.Fragment
+import com.google.android.material.snackbar.Snackbar
+import dagger.android.support.AndroidSupportInjection
+
+abstract class BaseFragment : Fragment() {
+
+ /****************************************************
+ * FRAGMENT LIFECYCLE
+ ***************************************************/
+
+ /**
+ * Is called when a fragment is connected to an activity.
+ */
+ override fun onAttach(context: Context) {
+ AndroidSupportInjection.inject(this)
+ super.onAttach(context)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onAttach");
+ }
+
+ /**
+ * Is called to do initial creation of the fragment.
+ */
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onCreate");
+
+ /* TODO("Initialize ViewModel here") */
+ }
+
+ /**
+ * Is called by Android once the Fragment should inflate a view.
+ */
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ parent: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onCreateView");
+
+ /* TODO("Defines the xml file for the fragment here") */
+
+ return null
+ }
+
+ /**
+ * Is called after onCreateView() and ensures that the fragment's root view is non-null.
+ * Any view setup should happen here. E.g., view lookups, attaching listeners.
+ */
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onViewCreated");
+
+ /* TODO("Setup any handles to view objects here") */
+
+ setupViews()
+ setupObservers()
+ }
+
+ override fun onCreateContextMenu(menu: ContextMenu, v: View, menuInfo: ContextMenuInfo?) {
+ super.onCreateContextMenu(menu, v, menuInfo)
+ }
+
+ override fun onContextItemSelected(item: MenuItem): Boolean {
+ return false
+ }
+
+ /**
+ * Is called when host activity has completed its onCreate() method.
+ */
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ super.onActivityCreated(savedInstanceState)
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onActivityCreated");
+ }
+
+ /**
+ * Is called once the fragment is ready to be displayed on screen.
+ */
+ override fun onStart() {
+ super.onStart()
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onStart");
+ }
+
+ /**
+ * Allocate “expensive” resources such as registering for location, sensor updates, etc.
+ */
+ override fun onResume() {
+ super.onResume()
+
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onResume");
+ }
+
+ /**
+ * Release “expensive” resources. Commit any changes.
+ */
+ override fun onPause() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onPause");
+
+ super.onPause()
+ }
+
+ /**
+ * All resources released.
+ */
+ override fun onStop() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onStop");
+ super.onStop()
+ }
+
+ /**
+ * Is called when fragment's view is being destroyed, but the fragment is still kept around.
+ */
+ override fun onDestroyView() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onDestroyView");
+
+ super.onDestroyView()
+ }
+
+ /**
+ * Is called when fragment is no longer in use.
+ */
+ override fun onDestroy() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onDestroy");
+
+ super.onDestroy()
+ }
+
+ /**
+ * Is called when fragment is no longer connected to the activity.
+ */
+ override fun onDetach() {
+ // DEBUG
+ Log.d(TAG, "Lifecycle - onDetach");
+
+ super.onDetach()
+ }
+
+ /****************************************************
+ * VIEW/DATA BINDING
+ ***************************************************/
+
+ protected abstract fun setupViews()
+
+ protected abstract fun setupItemsListView()
+
+ protected abstract fun setupErrorAnnounce()
+
+ protected fun showErrorToast(rootLayout: View, message: String?) {
+ message?.let {
+ Snackbar.make(rootLayout, message, Snackbar.LENGTH_LONG)
+ .show()
+ }
+ }
+
+ /****************************************************
+ * OBSERVERS
+ ***************************************************/
+
+ protected open fun setupObservers() {
+ // Nothing
+ }
+
+ companion object {
+ val TAG = BaseFragment::class.java.simpleName
+ const val FIRST_PAGE = 1
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/BaseViewModel.kt b/app/src/main/java/com/mydigipay/challenge/common/BaseViewModel.kt
new file mode 100644
index 00000000..0a1dc496
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/BaseViewModel.kt
@@ -0,0 +1,24 @@
+package com.mydigipay.challenge.common
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.ViewModel
+import io.reactivex.disposables.CompositeDisposable
+
+/**
+ * Reference:
+ * [https://github.com/googlesamples/android-architecture-components]
+ */
+
+open class BaseViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val disposable = CompositeDisposable()
+
+ override fun onCleared() {
+ if (!disposable.isDisposed) {
+ disposable.dispose()
+ }
+
+ super.onCleared()
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/help/EndlessScrollListener.kt b/app/src/main/java/com/mydigipay/challenge/common/help/EndlessScrollListener.kt
new file mode 100644
index 00000000..c0d7642b
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/help/EndlessScrollListener.kt
@@ -0,0 +1,65 @@
+package com.mydigipay.challenge.common.help
+
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * Reference:
+ * [https://gist.github.com/pratikbutani/dc6b963aa12200b3ad88aecd0d103872]
+ */
+
+abstract class EndlessScrollListener constructor(private val linearLayoutManager: LinearLayoutManager) :
+ RecyclerView.OnScrollListener() {
+
+ private var previousTotal = 0
+ private var loading = true
+ private val visibleThreshold = 4
+ private var currentItemCount: Int = 0
+ private var currentPage = 1
+ private var pageCount: Int? = null
+
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ super.onScrolled(recyclerView, dx, dy)
+
+ currentItemCount = linearLayoutManager.itemCount
+
+ if (currentItemCount < previousTotal) {
+ reset()
+ }
+
+ if (loading && isTotalItemCountRecentlyIncreased()) {
+ loading = false
+ previousTotal = currentItemCount
+ }
+
+ if (shouldLoadNextPage(visibleThreshold)) {
+ currentPage++
+ recyclerView.post { onLoadMore(currentPage) }
+ loading = true
+ }
+ }
+
+ private fun isTotalItemCountRecentlyIncreased(): Boolean {
+ return currentItemCount > previousTotal + visibleThreshold
+ }
+
+ private fun shouldLoadNextPage(threshold: Int): Boolean {
+ return !loading && isLastVisibleItemPositionExceedsTotalItemCount(threshold) && !isLastPage()
+ }
+
+ private fun isLastVisibleItemPositionExceedsTotalItemCount(threshold: Int): Boolean {
+ return linearLayoutManager.findLastVisibleItemPosition() + threshold >= currentItemCount
+ }
+
+ private fun isLastPage(): Boolean {
+ return pageCount != null && currentPage == pageCount
+ }
+
+ private fun reset() {
+ previousTotal = 0
+ loading = true
+ currentPage = 1
+ }
+
+ abstract fun onLoadMore(page: Int)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/help/Extensions.kt b/app/src/main/java/com/mydigipay/challenge/common/help/Extensions.kt
new file mode 100644
index 00000000..6f3a6dc5
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/help/Extensions.kt
@@ -0,0 +1,51 @@
+package com.mydigipay.challenge.common.help
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.databinding.DataBindingUtil
+import androidx.databinding.ViewDataBinding
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Observer
+import com.mydigipay.challenge.model.Resource
+import io.reactivex.Observable
+import io.reactivex.ObservableTransformer
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.disposables.Disposable
+
+/**
+ * References:
+ * [http://kotlinextensions.com/],
+ * [https://blog.danlew.net/2015/03/02/dont-break-the-chain/]
+ */
+
+fun ViewGroup?.inflate(
+ @LayoutRes layoutId: Int,
+ attachToParent: Boolean = true
+): T {
+ return DataBindingUtil.inflate(
+ LayoutInflater.from(this!!.context),
+ layoutId,
+ this,
+ attachToParent
+ )
+}
+
+fun applyLoading(): ObservableTransformer, Resource> = ObservableTransformer { upstream ->
+ Observable.just(Resource.loading()).concatWith(upstream)
+}
+
+fun LiveData.observeNonNull(owner: LifecycleOwner, observer: (t: T) -> Unit) {
+ this.observe(owner, Observer {
+ it?.let(observer)
+ })
+}
+
+operator fun CompositeDisposable.plusAssign(disposable: Disposable) {
+ add(disposable)
+}
+
+fun Any?.runIfNull(block: () -> Unit) {
+ if (this == null) block()
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/help/ImageBindingAdapter.kt b/app/src/main/java/com/mydigipay/challenge/common/help/ImageBindingAdapter.kt
new file mode 100644
index 00000000..0eb6af6d
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/help/ImageBindingAdapter.kt
@@ -0,0 +1,34 @@
+package com.mydigipay.challenge.common.help
+
+import android.view.View
+import android.widget.ImageView
+import androidx.databinding.BindingAdapter
+import com.bumptech.glide.Glide
+import com.mydigipay.challenge.R
+
+object ImageBindingAdapter {
+
+ @JvmStatic
+ @BindingAdapter("thumbnailImgUrl")
+ fun setThumbnailImgUrl(imageView: ImageView, thumbnailImgUrl: String?) {
+ Glide.with(imageView.context)
+ .load("https://yusmle.com/YUSMLE/items/pics/thumbs/$thumbnailImgUrl")
+ .placeholder(R.mipmap.ic_launcher_round)
+ .into(imageView)
+ }
+
+ @JvmStatic
+ @BindingAdapter("imgUrl")
+ fun setImgUrl(imageView: ImageView, imageUrl: String?) {
+ Glide.with(imageView.context)
+ .load("https://yusmle.com/YUSMLE/items/pics/$imageUrl")
+ .placeholder(R.mipmap.ic_launcher_round)
+ .into(imageView)
+ }
+
+ @JvmStatic
+ @BindingAdapter("visibleIf")
+ fun changeVisibility(view: View, visible: Boolean) {
+ view.visibility = if (visible) View.VISIBLE else View.GONE
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/help/SingleLiveEvent.kt b/app/src/main/java/com/mydigipay/challenge/common/help/SingleLiveEvent.kt
new file mode 100644
index 00000000..e2b50454
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/help/SingleLiveEvent.kt
@@ -0,0 +1,69 @@
+package com.mydigipay.challenge.common.help
+
+import android.util.Log
+import androidx.annotation.MainThread
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A lifecycle-aware observable that sends only new updates after subscription, used for events like
+ * navigation and SnackBar messages, in our case ViewEffect.
+ *
+ * This avoids a common problem with events: on configuration change (like rotation) an update
+ * can be emitted if the observer is active. This LiveData only calls the observable if there's an
+ * explicit call to setValue() or call().
+ *
+ * Note that only one observer is going to be notified of changes and there is no guarantee which one.
+ * If this causes any issues then use 'EVENT WRAPPER'.
+ *
+ * Event Wrapper Reference:
+ * [https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150]
+ *
+ * Source Reference:
+ * [https://github.com/android/architecture-samples]
+ */
+class SingleLiveEvent : MutableLiveData() {
+ private val pending = AtomicBoolean(false)
+
+ @MainThread
+ override fun observe(owner: LifecycleOwner, observer: Observer) {
+ if (hasActiveObservers()) {
+ Log.w(TAG, "Multiple observers registered but only one will be notified of changes.")
+ }
+
+ // Observe the internal MutableLiveData
+ super.observe(owner, Observer { t: T ->
+ if (pending.compareAndSet(true, false)) {
+ observer.onChanged(t)
+ }
+ })
+ }
+
+ @MainThread
+ override fun setValue(t: T?) {
+ pending.set(true)
+ super.setValue(t)
+ }
+
+ /**
+ * Used for cases where T is Void, to make calls cleaner.
+ */
+ @MainThread
+ fun call() {
+ value = null
+ }
+
+ /**
+ * Added by @Yusmle for cases where T is not Void, to make calls cleaner.
+ */
+ @MainThread
+ fun call(t: T) {
+ value = t
+ }
+
+ companion object {
+ private const val TAG = "SingleLiveEvent"
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/help/ViewModelFactory.kt b/app/src/main/java/com/mydigipay/challenge/common/help/ViewModelFactory.kt
new file mode 100644
index 00000000..2b9e684f
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/help/ViewModelFactory.kt
@@ -0,0 +1,37 @@
+package com.mydigipay.challenge.common.help
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/**
+ * Reference:
+ * [https://github.com/googlesamples/android-architecture-components]
+ */
+
+@Singleton
+class ViewModelFactory @Inject constructor(
+ private val viewModelMap: Map,
+ @JvmSuppressWildcards Provider>
+) : ViewModelProvider.Factory {
+
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ var viewModel = viewModelMap[modelClass]
+
+ if (viewModel == null) {
+ for (entry in viewModelMap) {
+ if (modelClass.isAssignableFrom(entry.key)) {
+ viewModel = entry.value
+ break
+ }
+ }
+ }
+
+ if (viewModel == null) throw IllegalArgumentException("Unknown model class: $modelClass")
+
+ return viewModel.get() as T
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/common/help/VisibilityBindingAdapter.kt b/app/src/main/java/com/mydigipay/challenge/common/help/VisibilityBindingAdapter.kt
new file mode 100644
index 00000000..f8d99a9f
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/common/help/VisibilityBindingAdapter.kt
@@ -0,0 +1,13 @@
+package com.mydigipay.challenge.common.help
+
+import android.view.View
+import androidx.databinding.BindingAdapter
+
+object VisibilityBindingAdapter {
+
+ @JvmStatic
+ @BindingAdapter("visibleGone")
+ fun showHide(view: View, show: Boolean) {
+ view.visibility = if (show) View.VISIBLE else View.GONE
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/components/AppComponent.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/components/AppComponent.kt
new file mode 100644
index 00000000..7bac5e35
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/components/AppComponent.kt
@@ -0,0 +1,34 @@
+package com.mydigipay.challenge.framework.di.components
+
+import android.app.Application
+import com.mydigipay.challenge.App
+import com.mydigipay.challenge.framework.di.modules.*
+import dagger.BindsInstance
+import dagger.Component
+import dagger.android.support.AndroidSupportInjectionModule
+import javax.inject.Singleton
+
+@Singleton
+@Component(
+ modules = [
+ AndroidSupportInjectionModule::class,
+ ActivityBuilderModule::class,
+ FragmentBuilderModule::class,
+ ViewModelModule::class,
+ DataSourceModule::class,
+ ContextModule::class
+ ]
+)
+interface AppComponent {
+
+ @Component.Builder
+ interface Builder {
+
+ @BindsInstance
+ fun app(application: Application): Builder
+
+ fun build(): AppComponent
+ }
+
+ fun inject(app: App)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/keys/ViewModelKey.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/keys/ViewModelKey.kt
new file mode 100644
index 00000000..597770f5
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/keys/ViewModelKey.kt
@@ -0,0 +1,14 @@
+package com.mydigipay.challenge.framework.di.keys
+
+import androidx.lifecycle.ViewModel
+import dagger.MapKey
+import kotlin.reflect.KClass
+
+@MapKey
+@Retention(AnnotationRetention.RUNTIME)
+@Target(
+ AnnotationTarget.FUNCTION,
+ AnnotationTarget.PROPERTY_GETTER,
+ AnnotationTarget.PROPERTY_SETTER
+)
+internal annotation class ViewModelKey(val value: KClass)
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ActivityBuilderModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ActivityBuilderModule.kt
new file mode 100644
index 00000000..bfe4252c
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ActivityBuilderModule.kt
@@ -0,0 +1,21 @@
+package com.mydigipay.challenge.framework.di.modules
+
+import com.mydigipay.challenge.framework.di.scopes.ActivityScope
+import com.mydigipay.challenge.presentation.view.di.AccessTokenActivityModule
+import com.mydigipay.challenge.presentation.view.di.GithubReposActivityModule
+import com.mydigipay.challenge.presentation.view.ui.AccessTokenActivity
+import com.mydigipay.challenge.presentation.view.ui.GithubReposActivity
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+
+@Module
+abstract class ActivityBuilderModule {
+
+ @ActivityScope
+ @ContributesAndroidInjector(modules = [AccessTokenActivityModule::class])
+ abstract fun bindAccessTokenActivity(): AccessTokenActivity
+
+ @ActivityScope
+ @ContributesAndroidInjector(modules = [GithubReposActivityModule::class])
+ abstract fun bindGithubReposActivity(): GithubReposActivity
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ContextModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ContextModule.kt
new file mode 100644
index 00000000..ce395eb3
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ContextModule.kt
@@ -0,0 +1,15 @@
+package com.mydigipay.challenge.framework.di.modules
+
+import android.app.Application
+import android.content.Context
+import dagger.Binds
+import dagger.Module
+import javax.inject.Singleton
+
+@Module
+abstract class ContextModule {
+
+ @Singleton
+ @Binds
+ abstract fun context(application: Application): Context
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/modules/DataSourceModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/DataSourceModule.kt
new file mode 100644
index 00000000..349bf7d5
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/DataSourceModule.kt
@@ -0,0 +1,46 @@
+package com.mydigipay.challenge.framework.di.modules
+
+import com.mydigipay.challenge.authorization.AccessTokenDataSource
+import com.mydigipay.challenge.framework.network.*
+import com.mydigipay.challenge.framework.network.mapper.AccessTokenMapper
+import com.mydigipay.challenge.framework.network.mapper.GithubRepoMapper
+import com.mydigipay.challenge.framework.preference.LocalAccessTokenDataSource
+import com.mydigipay.challenge.framework.preference.PreferenceModule
+import com.mydigipay.challenge.framework.preference.TokenDao
+import com.mydigipay.challenge.repositories.GithubRepoDataSource
+import dagger.Module
+import dagger.Provides
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module(includes = [PreferenceModule::class, NetworkModule::class])
+class DataSourceModule {
+
+ @Provides
+ @Singleton
+ @Named("remote")
+ fun provideRemoteAccessTokenDataSource(
+ service: RemoteAccessTokenService,
+ mapper: AccessTokenMapper
+ ): AccessTokenDataSource {
+ return RemoteAccessTokenDataSource(service, mapper)
+ }
+
+ @Provides
+ @Singleton
+ @Named("local")
+ fun provideLocalAccessTokenDataSource(
+ service: TokenDao
+ ): AccessTokenDataSource {
+ return LocalAccessTokenDataSource(service)
+ }
+
+ @Provides
+ @Singleton
+ fun provideRemoteGithubRepoDataSource(
+ service: RemoteGithubRepoService,
+ mapper: GithubRepoMapper
+ ): GithubRepoDataSource {
+ return RemoteGithubRepoDataSource(service, mapper)
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/modules/FragmentBuilderModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/FragmentBuilderModule.kt
new file mode 100644
index 00000000..9e9f92b5
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/FragmentBuilderModule.kt
@@ -0,0 +1,21 @@
+package com.mydigipay.challenge.framework.di.modules
+
+import com.mydigipay.challenge.framework.di.scopes.FragmentScope
+import com.mydigipay.challenge.presentation.view.di.GithubReposFragmentModule
+import com.mydigipay.challenge.presentation.view.di.RepoCommitsFragmentModule
+import com.mydigipay.challenge.presentation.view.ui.GithubReposFragment
+import com.mydigipay.challenge.presentation.view.ui.RepoCommitsFragment
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+
+@Module
+abstract class FragmentBuilderModule {
+
+ @FragmentScope
+ @ContributesAndroidInjector(modules = [GithubReposFragmentModule::class])
+ abstract fun bindGithubReposFragment(): GithubReposFragment
+
+ @FragmentScope
+ @ContributesAndroidInjector(modules = [RepoCommitsFragmentModule::class])
+ abstract fun bindRepoCommitsFragment(): RepoCommitsFragment
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ViewModelModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ViewModelModule.kt
new file mode 100644
index 00000000..df5318ed
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/modules/ViewModelModule.kt
@@ -0,0 +1,28 @@
+package com.mydigipay.challenge.framework.di.modules
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import com.mydigipay.challenge.common.help.ViewModelFactory
+import com.mydigipay.challenge.framework.di.keys.ViewModelKey
+import com.mydigipay.challenge.presentation.viewmodel.AccessTokenViewModel
+import com.mydigipay.challenge.presentation.viewmodel.GithubReposViewModel
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+
+@Module
+abstract class ViewModelModule {
+
+ @Binds
+ abstract fun bindViewModelFactory(viewModelFactory: ViewModelFactory): ViewModelProvider.Factory
+
+ @IntoMap
+ @Binds
+ @ViewModelKey(AccessTokenViewModel::class)
+ abstract fun provideAccessTokenViewModel(viewModel: AccessTokenViewModel): ViewModel
+
+ @IntoMap
+ @Binds
+ @ViewModelKey(GithubReposViewModel::class)
+ abstract fun provideGithubReposViewModel(viewModel: GithubReposViewModel): ViewModel
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/qualifiers/ActivityContext.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/qualifiers/ActivityContext.kt
new file mode 100644
index 00000000..c895744f
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/qualifiers/ActivityContext.kt
@@ -0,0 +1,6 @@
+package com.mydigipay.challenge.framework.di.qualifiers
+
+import javax.inject.Qualifier
+
+@Qualifier
+annotation class ActivityContext
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/qualifiers/ApplicationContext.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/qualifiers/ApplicationContext.kt
new file mode 100644
index 00000000..9b1de264
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/qualifiers/ApplicationContext.kt
@@ -0,0 +1,6 @@
+package com.mydigipay.challenge.framework.di.qualifiers
+
+import javax.inject.Qualifier
+
+@Qualifier
+annotation class ApplicationContext
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/ActivityScope.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/ActivityScope.kt
new file mode 100644
index 00000000..8bca4044
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/ActivityScope.kt
@@ -0,0 +1,7 @@
+package com.mydigipay.challenge.framework.di.scopes
+
+import javax.inject.Scope
+
+@Scope
+@Retention(AnnotationRetention.RUNTIME)
+internal annotation class ActivityScope
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/ApplicationScope.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/ApplicationScope.kt
new file mode 100644
index 00000000..f3f4cbf3
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/ApplicationScope.kt
@@ -0,0 +1,9 @@
+package com.mydigipay.challenge.framework.di.scopes
+
+import java.lang.annotation.Retention
+import java.lang.annotation.RetentionPolicy
+import javax.inject.Scope
+
+@Scope
+@Retention(RetentionPolicy.CLASS)
+internal annotation class ApplicationScope
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/FragmentScope.kt b/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/FragmentScope.kt
new file mode 100644
index 00000000..1f3269e2
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/di/scopes/FragmentScope.kt
@@ -0,0 +1,7 @@
+package com.mydigipay.challenge.framework.di.scopes
+
+import javax.inject.Scope
+
+@Scope
+@Retention(AnnotationRetention.RUNTIME)
+internal annotation class FragmentScope
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/NetworkModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/NetworkModule.kt
new file mode 100644
index 00000000..887b01f2
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/NetworkModule.kt
@@ -0,0 +1,92 @@
+package com.mydigipay.challenge.framework.network
+
+import com.mydigipay.challenge.ACCESS_TOKEN_URL
+import com.mydigipay.challenge.REST_API_URL
+import dagger.Module
+import dagger.Provides
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+class NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideLoggingInterceptor(): HttpLoggingInterceptor {
+ val httpLoggingInterceptor = HttpLoggingInterceptor()
+ return httpLoggingInterceptor.apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ }
+ }
+
+ /**
+ * AccessToken
+ */
+
+ @Provides
+ @Singleton
+ @Named("access_token")
+ fun provideOkHttpClientForAccessToken(
+ loggingInterceptor: HttpLoggingInterceptor
+ ): OkHttpClient {
+ return OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ @Named("access_token")
+ fun provideRetrofitForAccessToken(@Named("access_token") okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl(ACCESS_TOKEN_URL)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRemoteAccessTokenService(@Named("access_token") retrofit: Retrofit): RemoteAccessTokenService {
+ return retrofit.create(RemoteAccessTokenService::class.java)
+ }
+
+ /**
+ * RestApi
+ */
+
+ @Provides
+ @Singleton
+ @Named("rest_api")
+ fun provideOkHttpClientForRestApi(
+ loggingInterceptor: HttpLoggingInterceptor,
+ requestInterceptor: RequestInterceptor
+ ): OkHttpClient {
+ return OkHttpClient.Builder()
+ .addInterceptor(loggingInterceptor)
+ .addInterceptor(requestInterceptor)
+ .build()
+ }
+
+
+ @Provides
+ @Singleton
+ @Named("rest_api")
+ fun provideRetrofitForForRestApi(@Named("rest_api") okHttpClient: OkHttpClient): Retrofit {
+ return Retrofit.Builder()
+ .baseUrl(REST_API_URL)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRemoteGithubRepoService(@Named("rest_api") retrofit: Retrofit): RemoteGithubRepoService {
+ return retrofit.create(RemoteGithubRepoService::class.java)
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteAccessTokenDataSource.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteAccessTokenDataSource.kt
new file mode 100644
index 00000000..f3852b06
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteAccessTokenDataSource.kt
@@ -0,0 +1,47 @@
+package com.mydigipay.challenge.framework.network
+
+import android.util.Log
+import com.mydigipay.challenge.authorization.AccessToken
+import com.mydigipay.challenge.authorization.AccessTokenDataSource
+import com.mydigipay.challenge.framework.network.mapper.AccessTokenMapper
+import com.mydigipay.challenge.framework.network.request.AccessTokenRequest
+import com.mydigipay.challenge.model.Resource
+import java.io.IOException
+import javax.inject.Inject
+
+class RemoteAccessTokenDataSource @Inject constructor(
+ private val service: RemoteAccessTokenService,
+ private val mapper: AccessTokenMapper
+) : AccessTokenDataSource {
+
+ override suspend fun getAccessToken(
+ clientId: String,
+ clientSecret: String,
+ code: String,
+ redirectUri: String,
+ state: String
+ ): Resource {
+ return try {
+ val response = service.fetchAccessToken(
+ AccessTokenRequest(clientId, clientSecret, code, redirectUri, state)
+ )
+
+ return if (response.isSuccessful) {
+ Resource.success(mapper.transformToModel(response.body()!!)!!)
+ }
+ else {
+ /* Handle standard error codes, by checking [response.code()] */
+
+ Resource.error(
+ IOException(response.errorBody()?.string() ?: "Something goes wrong")
+ )
+ }
+ }
+ catch (e: Exception) {
+ // DEBUG
+ Log.e("getAccessToken", e.message ?: "Internet error runs")
+
+ Resource.error(IOException(e.message ?: "Internet error runs"))
+ }
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteAccessTokenService.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteAccessTokenService.kt
new file mode 100644
index 00000000..392e72e2
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteAccessTokenService.kt
@@ -0,0 +1,15 @@
+package com.mydigipay.challenge.framework.network
+
+import com.mydigipay.challenge.framework.network.request.AccessTokenRequest
+import com.mydigipay.challenge.framework.network.response.AccessTokenResponse
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.Headers
+import retrofit2.http.POST
+
+interface RemoteAccessTokenService {
+
+ @Headers("Accept:application/json")
+ @POST(".")
+ suspend fun fetchAccessToken(@Body accessTokenRequest: AccessTokenRequest): Response
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteGithubRepoDataSource.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteGithubRepoDataSource.kt
new file mode 100644
index 00000000..edb984c7
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteGithubRepoDataSource.kt
@@ -0,0 +1,38 @@
+package com.mydigipay.challenge.framework.network
+
+import android.util.Log
+import com.mydigipay.challenge.framework.network.mapper.GithubRepoMapper
+import com.mydigipay.challenge.model.Resource
+import com.mydigipay.challenge.repositories.GithubRepo
+import com.mydigipay.challenge.repositories.GithubRepoDataSource
+import java.io.IOException
+import javax.inject.Inject
+
+class RemoteGithubRepoDataSource @Inject constructor(
+ private val service: RemoteGithubRepoService,
+ private val mapper: GithubRepoMapper
+) : GithubRepoDataSource {
+
+ override suspend fun getGithubRepos(since: Int, token: String): Resource> {
+ return try {
+ val response = service.fetchGithubRepos(since, token)
+
+ return if (response.isSuccessful) {
+ Resource.success(mapper.transformToModels(response.body()!!))
+ }
+ else {
+ /* Handle standard error codes, by checking [response.code()] */
+
+ Resource.error(
+ IOException(response.errorBody()?.string() ?: "Something goes wrong")
+ )
+ }
+ }
+ catch (e: Exception) {
+ // DEBUG
+ Log.e("getGithubRepos", e.message ?: "Internet error runs")
+
+ Resource.error(IOException(e.message ?: "Internet error runs"))
+ }
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteGithubRepoService.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteGithubRepoService.kt
new file mode 100644
index 00000000..44a04506
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/RemoteGithubRepoService.kt
@@ -0,0 +1,15 @@
+package com.mydigipay.challenge.framework.network
+
+import com.mydigipay.challenge.framework.network.response.GithubRepoResponse
+import retrofit2.Response
+import retrofit2.http.*
+
+interface RemoteGithubRepoService {
+
+ @Headers("Accept:application/json")
+ @GET("repositories")
+ suspend fun fetchGithubRepos(
+ @Query("since") since: Int,
+ @Header("Authorization") token: String
+ ): Response>
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/RequestInterceptor.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/RequestInterceptor.kt
new file mode 100644
index 00000000..35b8a458
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/RequestInterceptor.kt
@@ -0,0 +1,32 @@
+package com.mydigipay.challenge.framework.network
+
+import com.mydigipay.challenge.CLIENT_ID
+import com.mydigipay.challenge.CLIENT_SECRET
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RequestInterceptor @Inject constructor() : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ var request = chain.request()
+
+ val httpUrl = request.url.newBuilder()
+ //.addQueryParameter(CLIENT_ID_QUERY, CLIENT_ID_VALUE)
+ //.addQueryParameter(CLIENT_SECRET_QUERY, CLIENT_SECRET_VALUE)
+ .build()
+
+ request = request.newBuilder().url(httpUrl).build()
+
+ return chain.proceed(request)
+ }
+
+ companion object {
+ const val CLIENT_ID_QUERY = "client_id"
+ const val CLIENT_ID_VALUE = CLIENT_ID
+ const val CLIENT_SECRET_QUERY = "client_secret"
+ const val CLIENT_SECRET_VALUE = CLIENT_SECRET
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/AccessTokenMapper.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/AccessTokenMapper.kt
new file mode 100644
index 00000000..22210cba
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/AccessTokenMapper.kt
@@ -0,0 +1,21 @@
+package com.mydigipay.challenge.framework.network.mapper
+
+import com.mydigipay.challenge.authorization.AccessToken
+import com.mydigipay.challenge.framework.network.response.AccessTokenResponse
+import com.mydigipay.challenge.mapper.DataMapper
+import javax.inject.Inject
+
+class AccessTokenMapper @Inject constructor() : DataMapper() {
+
+ override fun transformToEntity(model: AccessToken): AccessTokenResponse? {
+ // Unnecessary transform
+ return null
+ }
+
+ override fun transformToModel(entity: AccessTokenResponse): AccessToken? {
+ return AccessToken(
+ entity.accessToken,
+ entity.tokenType
+ )
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/GithubRepoMapper.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/GithubRepoMapper.kt
new file mode 100644
index 00000000..b039fd56
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/GithubRepoMapper.kt
@@ -0,0 +1,67 @@
+package com.mydigipay.challenge.framework.network.mapper
+
+import com.mydigipay.challenge.framework.network.response.GithubRepoResponse
+import com.mydigipay.challenge.mapper.DataMapper
+import com.mydigipay.challenge.repositories.GithubRepo
+import javax.inject.Inject
+
+class GithubRepoMapper @Inject constructor(
+ private val repoOwnerMapper: RepoOwnerMapper
+) : DataMapper() {
+
+ override fun transformToEntity(model: GithubRepo): GithubRepoResponse? {
+ // Unnecessary transform
+ return null
+ }
+
+ override fun transformToModel(entity: GithubRepoResponse): GithubRepo? {
+ return GithubRepo(
+ entity.id,
+ entity.nodeId,
+ entity.name,
+ entity.fullName,
+ entity.`private`,
+ repoOwnerMapper.transformToModel(entity.repoOwnerResponse)!!,
+ entity.htmlUrl,
+ entity.description,
+ entity.fork,
+ entity.url,
+ entity.forksUrl,
+ entity.keysUrl,
+ entity.collaboratorsUrl,
+ entity.teamsUrl,
+ entity.hooksUrl,
+ entity.issueEventsUrl,
+ entity.eventsUrl,
+ entity.assigneesUrl,
+ entity.branchesUrl,
+ entity.tagsUrl,
+ entity.blobsUrl,
+ entity.gitTagsUrl,
+ entity.gitRefsUrl,
+ entity.treesUrl,
+ entity.statusesUrl,
+ entity.languagesUrl,
+ entity.stargazersUrl,
+ entity.contributorsUrl,
+ entity.subscribersUrl,
+ entity.subscriptionUrl,
+ entity.commitsUrl,
+ entity.gitCommitsUrl,
+ entity.commentsUrl,
+ entity.issueCommentUrl,
+ entity.contentsUrl,
+ entity.compareUrl,
+ entity.mergesUrl,
+ entity.archiveUrl,
+ entity.downloadsUrl,
+ entity.issuesUrl,
+ entity.pullsUrl,
+ entity.milestonesUrl,
+ entity.notificationsUrl,
+ entity.labelsUrl,
+ entity.releasesUrl,
+ entity.deploymentsUrl
+ )
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/RepoOwnerMapper.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/RepoOwnerMapper.kt
new file mode 100644
index 00000000..53bd57e1
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/mapper/RepoOwnerMapper.kt
@@ -0,0 +1,37 @@
+package com.mydigipay.challenge.framework.network.mapper
+
+import com.mydigipay.challenge.framework.network.response.RepoOwnerResponse
+import com.mydigipay.challenge.mapper.DataMapper
+import com.mydigipay.challenge.repositories.RepoOwner
+import javax.inject.Inject
+
+class RepoOwnerMapper @Inject constructor() : DataMapper() {
+
+ override fun transformToEntity(model: RepoOwner): RepoOwnerResponse? {
+ // Unnecessary transform
+ return null
+ }
+
+ override fun transformToModel(entity: RepoOwnerResponse): RepoOwner? {
+ return RepoOwner(
+ entity.login,
+ entity.id,
+ entity.nodeId,
+ entity.avatarUrl,
+ entity.grAvatarId,
+ entity.url,
+ entity.htmlUrl,
+ entity.followersUrl,
+ entity.followingUrl,
+ entity.gistsUrl,
+ entity.starredUrl,
+ entity.subscriptionsUrl,
+ entity.organizationsUrl,
+ entity.reposUrl,
+ entity.eventsUrl,
+ entity.receivedEventsUrl,
+ entity.type,
+ entity.siteAdmin
+ )
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/network/oauth/RequestAccessToken.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/request/AccessTokenRequest.kt
similarity index 79%
rename from app/src/main/java/com/mydigipay/challenge/network/oauth/RequestAccessToken.kt
rename to app/src/main/java/com/mydigipay/challenge/framework/network/request/AccessTokenRequest.kt
index 22e2aa0b..2ec03253 100644
--- a/app/src/main/java/com/mydigipay/challenge/network/oauth/RequestAccessToken.kt
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/request/AccessTokenRequest.kt
@@ -1,8 +1,8 @@
-package com.mydigipay.challenge.network.oauth
+package com.mydigipay.challenge.framework.network.request
import com.google.gson.annotations.SerializedName
-data class RequestAccessToken(
+data class AccessTokenRequest(
@SerializedName("client_id")
var clientId: String,
@@ -17,4 +17,4 @@ data class RequestAccessToken(
@SerializedName("state")
var state: String
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/mydigipay/challenge/network/oauth/ResponseAccessToken.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/response/AccessTokenResponse.kt
similarity index 65%
rename from app/src/main/java/com/mydigipay/challenge/network/oauth/ResponseAccessToken.kt
rename to app/src/main/java/com/mydigipay/challenge/framework/network/response/AccessTokenResponse.kt
index d79c2340..0fe83a44 100644
--- a/app/src/main/java/com/mydigipay/challenge/network/oauth/ResponseAccessToken.kt
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/response/AccessTokenResponse.kt
@@ -1,11 +1,11 @@
-package com.mydigipay.challenge.network.oauth
+package com.mydigipay.challenge.framework.network.response
import com.google.gson.annotations.SerializedName
-data class ResponseAccessToken(
+data class AccessTokenResponse(
@SerializedName("access_token")
var accessToken: String,
@SerializedName("token_type")
var tokenType: String
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/response/GithubRepoResponse.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/response/GithubRepoResponse.kt
new file mode 100644
index 00000000..70f6af18
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/response/GithubRepoResponse.kt
@@ -0,0 +1,98 @@
+package com.mydigipay.challenge.framework.network.response
+
+import com.google.gson.annotations.SerializedName
+
+data class GithubRepoResponse(
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("node_id")
+ val nodeId: String,
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("full_name")
+ val fullName: String,
+ @SerializedName("private")
+ val `private`: Boolean,
+ @SerializedName("owner")
+ val repoOwnerResponse: RepoOwnerResponse,
+ @SerializedName("html_url")
+ val htmlUrl: String,
+ @SerializedName("description")
+ val description: String?,
+ @SerializedName("fork")
+ val fork: Boolean,
+ @SerializedName("url")
+ val url: String,
+ @SerializedName("forks_url")
+ val forksUrl: String,
+ @SerializedName("keys_url")
+ val keysUrl: String,
+ @SerializedName("collaborators_url")
+ val collaboratorsUrl: String,
+ @SerializedName("teams_url")
+ val teamsUrl: String,
+ @SerializedName("hooks_url")
+ val hooksUrl: String,
+ @SerializedName("issue_events_url")
+ val issueEventsUrl: String,
+ @SerializedName("events_url")
+ val eventsUrl: String,
+ @SerializedName("assignees_url")
+ val assigneesUrl: String,
+ @SerializedName("branches_url")
+ val branchesUrl: String,
+ @SerializedName("tags_url")
+ val tagsUrl: String,
+ @SerializedName("blobs_url")
+ val blobsUrl: String,
+ @SerializedName("git_tags_url")
+ val gitTagsUrl: String,
+ @SerializedName("git_refs_url")
+ val gitRefsUrl: String,
+ @SerializedName("trees_url")
+ val treesUrl: String,
+ @SerializedName("statuses_url")
+ val statusesUrl: String,
+ @SerializedName("languages_url")
+ val languagesUrl: String,
+ @SerializedName("stargazers_url")
+ val stargazersUrl: String,
+ @SerializedName("contributors_url")
+ val contributorsUrl: String,
+ @SerializedName("subscribers_url")
+ val subscribersUrl: String,
+ @SerializedName("subscription_url")
+ val subscriptionUrl: String,
+ @SerializedName("commits_url")
+ val commitsUrl: String,
+ @SerializedName("git_commits_url")
+ val gitCommitsUrl: String,
+ @SerializedName("comments_url")
+ val commentsUrl: String,
+ @SerializedName("issue_comment_url")
+ val issueCommentUrl: String,
+ @SerializedName("contents_url")
+ val contentsUrl: String,
+ @SerializedName("compare_url")
+ val compareUrl: String,
+ @SerializedName("merges_url")
+ val mergesUrl: String,
+ @SerializedName("archive_url")
+ val archiveUrl: String,
+ @SerializedName("downloads_url")
+ val downloadsUrl: String,
+ @SerializedName("issues_url")
+ val issuesUrl: String,
+ @SerializedName("pulls_url")
+ val pullsUrl: String,
+ @SerializedName("milestones_url")
+ val milestonesUrl: String,
+ @SerializedName("notifications_url")
+ val notificationsUrl: String,
+ @SerializedName("labels_url")
+ val labelsUrl: String,
+ @SerializedName("releases_url")
+ val releasesUrl: String,
+ @SerializedName("deployments_url")
+ val deploymentsUrl: String
+)
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/network/response/RepoOwnerResponse.kt b/app/src/main/java/com/mydigipay/challenge/framework/network/response/RepoOwnerResponse.kt
new file mode 100644
index 00000000..024a5750
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/network/response/RepoOwnerResponse.kt
@@ -0,0 +1,42 @@
+package com.mydigipay.challenge.framework.network.response
+
+import com.google.gson.annotations.SerializedName
+
+data class RepoOwnerResponse(
+ @SerializedName("login")
+ val login: String,
+ @SerializedName("id")
+ val id: Int,
+ @SerializedName("node_id")
+ val nodeId: String,
+ @SerializedName("avatar_url")
+ val avatarUrl: String,
+ @SerializedName("gravatar_id")
+ val grAvatarId: String,
+ @SerializedName("url")
+ val url: String,
+ @SerializedName("html_url")
+ val htmlUrl: String,
+ @SerializedName("followers_url")
+ val followersUrl: String,
+ @SerializedName("following_url")
+ val followingUrl: String,
+ @SerializedName("gists_url")
+ val gistsUrl: String,
+ @SerializedName("starred_url")
+ val starredUrl: String,
+ @SerializedName("subscriptions_url")
+ val subscriptionsUrl: String,
+ @SerializedName("organizations_url")
+ val organizationsUrl: String,
+ @SerializedName("repos_url")
+ val reposUrl: String,
+ @SerializedName("events_url")
+ val eventsUrl: String,
+ @SerializedName("received_events_url")
+ val receivedEventsUrl: String,
+ @SerializedName("type")
+ val type: String,
+ @SerializedName("site_admin")
+ val siteAdmin: Boolean
+)
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/preference/LocalAccessTokenDataSource.kt b/app/src/main/java/com/mydigipay/challenge/framework/preference/LocalAccessTokenDataSource.kt
new file mode 100644
index 00000000..f3a71b49
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/preference/LocalAccessTokenDataSource.kt
@@ -0,0 +1,18 @@
+package com.mydigipay.challenge.framework.preference
+
+import com.mydigipay.challenge.authorization.AccessTokenDataSource
+import com.mydigipay.challenge.model.Resource
+import javax.inject.Inject
+
+class LocalAccessTokenDataSource @Inject constructor(
+ private val service: TokenDao
+) : AccessTokenDataSource {
+
+ override suspend fun saveAccessToken(token: String): Resource {
+ return Resource.success(service.saveAccessTokenAsync(token).await())
+ }
+
+ override suspend fun readAccessToken(): Resource {
+ return Resource.success(service.readAccessTokenAsync().await())
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/preference/PreferenceModule.kt b/app/src/main/java/com/mydigipay/challenge/framework/preference/PreferenceModule.kt
new file mode 100644
index 00000000..218eda7f
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/preference/PreferenceModule.kt
@@ -0,0 +1,22 @@
+package com.mydigipay.challenge.framework.preference
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.preference.PreferenceManager
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class PreferenceModule {
+
+ @Provides
+ @Singleton
+ fun provideSharedPreferences(context: Context): SharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(context)
+
+ @Provides
+ @Singleton
+ fun provideTokenDao(sharedPreferences: SharedPreferences): TokenDao =
+ TokenDaoImpl(sharedPreferences)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/preference/TokenDao.kt b/app/src/main/java/com/mydigipay/challenge/framework/preference/TokenDao.kt
new file mode 100644
index 00000000..5704933c
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/preference/TokenDao.kt
@@ -0,0 +1,10 @@
+package com.mydigipay.challenge.framework.preference
+
+import kotlinx.coroutines.Deferred
+
+interface TokenDao {
+
+ fun saveAccessTokenAsync(token: String): Deferred
+
+ fun readAccessTokenAsync(): Deferred
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/framework/preference/TokenDaoImpl.kt b/app/src/main/java/com/mydigipay/challenge/framework/preference/TokenDaoImpl.kt
new file mode 100644
index 00000000..55fc5581
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/framework/preference/TokenDaoImpl.kt
@@ -0,0 +1,33 @@
+package com.mydigipay.challenge.framework.preference
+
+import android.content.SharedPreferences
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import javax.inject.Inject
+
+class TokenDaoImpl @Inject constructor(
+ private val sharedPreferences: SharedPreferences
+) : TokenDao {
+
+ override fun saveAccessTokenAsync(token: String): Deferred =
+ CoroutineScope(Dispatchers.IO).async {
+ sharedPreferences.edit().apply {
+ putString(KEY_TOKEN, token)
+ }.apply()
+ }
+
+ override fun readAccessTokenAsync(): Deferred =
+ CoroutineScope(Dispatchers.IO).async {
+ sharedPreferences.getString(KEY_TOKEN, "") ?: ""
+ }
+
+ /**
+ * SharedPreferences Keys
+ */
+
+ companion object {
+ const val KEY_TOKEN = "KEY_TOKEN"
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/github/LoginUriActivity.kt b/app/src/main/java/com/mydigipay/challenge/github/LoginUriActivity.kt
deleted file mode 100644
index 399278ed..00000000
--- a/app/src/main/java/com/mydigipay/challenge/github/LoginUriActivity.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package com.mydigipay.challenge.github
-
-import android.app.Activity
-import android.content.Intent
-import android.os.Bundle
-import com.mydigipay.challenge.network.oauth.RequestAccessToken
-import com.mydigipay.challenge.repository.oauth.AccessTokenDataSource
-import com.mydigipay.challenge.repository.token.TokenRepository
-import kotlinx.android.synthetic.main.login_uri_activity.*
-import kotlinx.coroutines.*
-import org.koin.android.ext.android.inject
-
-class LoginUriActivity : Activity() {
- private val tokenRepository: TokenRepository by inject()
- private val accessTokenDataSource: AccessTokenDataSource by inject()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.login_uri_activity)
- }
-
- override fun onResume() {
- super.onResume()
-
- val intent = intent
- if (Intent.ACTION_VIEW == intent.action) {
- val uri = intent.data
- val code = uri?.getQueryParameter("code") ?: ""
- code.takeIf { it.isNotEmpty() }?.let { code ->
- val accessTokenJob = CoroutineScope(Dispatchers.IO).launch {
- val response = accessTokenDataSource.accessToken(
- RequestAccessToken(
- CLIENT_ID,
- CLIENT_SECRET,
- code,
- REDIRECT_URI,
- "0"
- )
- ).await()
-
- tokenRepository.saveToken(response.accessToken).await()
- }
-
- accessTokenJob.invokeOnCompletion {
- CoroutineScope(Dispatchers.Main).launch {
- token.text = tokenRepository.readToken().await()
- this.cancel()
- accessTokenJob.cancelAndJoin()
- }
- }
- } ?: run { finish() }
- }
-
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/mydigipay/challenge/github/MainActivity.kt b/app/src/main/java/com/mydigipay/challenge/github/MainActivity.kt
deleted file mode 100644
index 3ba92da9..00000000
--- a/app/src/main/java/com/mydigipay/challenge/github/MainActivity.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package com.mydigipay.challenge.github
-
-import android.content.Intent
-import android.net.Uri
-import android.os.Bundle
-import androidx.appcompat.app.AppCompatActivity
-import kotlinx.android.synthetic.main.activity_main.*
-
-const val CLIENT_ID = "CLIENT_ID"
-const val CLIENT_SECRET = "CLIENT_SECRET"
-const val REDIRECT_URI = "REDIRECT_URI"
-
-class MainActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_main)
-
- authorize.setOnClickListener { view ->
- val url = "https://github.com/login/oauth/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=repo user&state=0"
- val i = Intent(Intent.ACTION_VIEW)
- i.data = Uri.parse(url)
- startActivity(i)
- }
- }
-}
diff --git a/app/src/main/java/com/mydigipay/challenge/mapper/DataMapper.kt b/app/src/main/java/com/mydigipay/challenge/mapper/DataMapper.kt
new file mode 100644
index 00000000..13ef0eed
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/mapper/DataMapper.kt
@@ -0,0 +1,26 @@
+package com.mydigipay.challenge.mapper
+
+abstract class DataMapper {
+
+ abstract fun transformToEntity(model: M): E?
+
+ abstract fun transformToModel(entity: E): M?
+
+ fun transformToEntities(models: List): List {
+ val entities: MutableList = mutableListOf()
+ for (model in models) {
+ transformToEntity(model)?.let { entities.add(it) }
+ }
+
+ return entities
+ }
+
+ fun transformToModels(entities: List): List {
+ val models: MutableList = mutableListOf()
+ for (entity in entities) {
+ transformToModel(entity)?.let { models.add(it) }
+ }
+
+ return models
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/network/di/AccessTokenModule.kt b/app/src/main/java/com/mydigipay/challenge/network/di/AccessTokenModule.kt
deleted file mode 100644
index 34fcc6cf..00000000
--- a/app/src/main/java/com/mydigipay/challenge/network/di/AccessTokenModule.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.mydigipay.challenge.network.di
-
-import com.mydigipay.challenge.network.oauth.AccessTokenService
-import com.mydigipay.challenge.repository.oauth.AccessTokenDataSource
-import com.mydigipay.challenge.repository.oauth.AccessTokenDataSourceImpl
-import org.koin.core.qualifier.named
-import org.koin.dsl.module
-import retrofit2.Retrofit
-
-val accessTokenModule = module {
- factory { get(named(RETROFIT)).create(AccessTokenService::class.java) }
- factory { AccessTokenDataSourceImpl(get()) as AccessTokenDataSource }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/mydigipay/challenge/network/di/NetworkModule.kt b/app/src/main/java/com/mydigipay/challenge/network/di/NetworkModule.kt
deleted file mode 100644
index 60ae9e5b..00000000
--- a/app/src/main/java/com/mydigipay/challenge/network/di/NetworkModule.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.mydigipay.challenge.network.di
-
-import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
-import com.mydigipay.challenge.repository.token.TokenRepository
-import com.mydigipay.challenge.repository.token.TokenRepositoryImpl
-import okhttp3.Interceptor
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import org.koin.core.qualifier.named
-import org.koin.dsl.module
-import retrofit2.Retrofit
-import retrofit2.converter.gson.GsonConverterFactory
-import java.util.concurrent.TimeUnit
-
-const val OK_HTTP = "OK_HTTP"
-const val RETROFIT = "RETROFIT"
-const val READ_TIMEOUT = "READ_TIMEOUT"
-const val WRITE_TIMEOUT = "WRITE_TIMEOUT"
-const val CONNECTION_TIMEOUT = "CONNECTION_TIMEOUT"
-val networkModule = module {
-
- single(named(READ_TIMEOUT)) { 30 * 1000 }
- single(named(WRITE_TIMEOUT)) { 10 * 1000 }
- single(named(CONNECTION_TIMEOUT)) { 10 * 1000 }
-
- factory {
- HttpLoggingInterceptor()
- .setLevel(HttpLoggingInterceptor.Level.HEADERS)
- .setLevel(HttpLoggingInterceptor.Level.BODY)
- }
-
- factory(named(OK_HTTP)) {
- OkHttpClient.Builder()
- .readTimeout(get(named(READ_TIMEOUT)), TimeUnit.MILLISECONDS)
- .writeTimeout(get(named(WRITE_TIMEOUT)), TimeUnit.MILLISECONDS)
- .connectTimeout(get(named(CONNECTION_TIMEOUT)), TimeUnit.MILLISECONDS)
- .addInterceptor(get())
- .build()
- }
-
- single(named(RETROFIT)) {
- Retrofit.Builder()
- .client(get(named(OK_HTTP)))
- .baseUrl("http://api.github.com")
- .addConverterFactory(GsonConverterFactory.create())
- .addCallAdapterFactory(CoroutineCallAdapterFactory())
- .build()
- }
-
- single {
- TokenRepositoryImpl(get()) as TokenRepository
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/mydigipay/challenge/network/oauth/AccessTokenService.kt b/app/src/main/java/com/mydigipay/challenge/network/oauth/AccessTokenService.kt
deleted file mode 100644
index 38dd31ad..00000000
--- a/app/src/main/java/com/mydigipay/challenge/network/oauth/AccessTokenService.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.mydigipay.challenge.network.oauth
-
-import kotlinx.coroutines.Deferred
-import retrofit2.http.Body
-import retrofit2.http.Headers
-import retrofit2.http.POST
-
-interface AccessTokenService {
- @Headers("Accept:application/json")
- @POST("https://github.com/login/oauth/access_token")
- fun accessToken(@Body requestAccessToken: RequestAccessToken) : Deferred
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/design/MviActivity.kt b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviActivity.kt
new file mode 100644
index 00000000..7ed7c1af
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviActivity.kt
@@ -0,0 +1,44 @@
+package com.mydigipay.challenge.presentation.design
+
+import android.os.Bundle
+import android.util.Log
+import androidx.lifecycle.Observer
+import com.mydigipay.challenge.common.BaseActivity
+
+/**
+ * Reference:
+ * [https://github.com/RohitSurwase/AAC-MVI-Architecture]
+ */
+
+abstract class MviActivity> :
+ BaseActivity() {
+
+ lateinit var viewModel: ViewModel
+
+ protected val viewStateObserver = Observer {
+ // DEBUG
+ Log.d(TAG, "observed viewState : $it")
+
+ renderViewState(it)
+ }
+
+ protected val viewEffectObserver = Observer {
+ // DEBUG
+ Log.d(TAG, "observed viewEffect : $it")
+
+ renderViewEffect(it)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ /**
+ * Consider to start observing viewStates and viewEffects here,
+ * using viewStateObserver and viewEffectObserver.
+ */
+ }
+
+ abstract fun renderViewState(viewState: STATE)
+
+ abstract fun renderViewEffect(viewEffect: EFFECT)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/design/MviCustomView.kt b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviCustomView.kt
new file mode 100644
index 00000000..f9967a2f
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviCustomView.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Rohit Surwase
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mydigipay.challenge.presentation.design
+
+import android.util.Log
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.Observer
+
+/**
+ * Reference:
+ * [https://github.com/RohitSurwase/AAC-MVI-Architecture]
+ */
+
+abstract class MviCustomView> {
+
+ abstract val viewModel: ViewModel
+
+ private val viewStateObserver = Observer {
+ // DEBUG
+ Log.d(TAG, "observed viewState : $it")
+
+ renderViewState(it)
+ }
+
+ private val viewEffectObserver = Observer {
+ // DEBUG
+ Log.d(TAG, "observed viewEffect : $it")
+
+ renderViewEffect(it)
+ }
+
+ /**
+ * Consider to start observing viewStates and viewEffects here,
+ * using viewStateObserver and viewEffectObserver.
+ */
+ fun startObserving(lifecycleOwner: LifecycleOwner) {
+ viewModel.viewStates().observe(lifecycleOwner, viewStateObserver)
+ viewModel.viewEffects().observe(lifecycleOwner, viewEffectObserver)
+ }
+
+ abstract fun renderViewState(viewState: STATE)
+
+ abstract fun renderViewEffect(viewEffect: EFFECT)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/design/MviFragment.kt b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviFragment.kt
new file mode 100644
index 00000000..b845cbca
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviFragment.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2020 Rohit Surwase
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.mydigipay.challenge.presentation.design
+
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.lifecycle.Observer
+import com.mydigipay.challenge.common.BaseFragment
+
+/**
+ * Reference:
+ * [https://github.com/RohitSurwase/AAC-MVI-Architecture]
+ */
+
+abstract class MviFragment> :
+ BaseFragment() {
+
+ lateinit var viewModel: ViewModel
+
+ protected val viewStateObserver = Observer {
+ // DEBUG
+ Log.d(TAG, "observed viewState : $it")
+
+ renderViewState(it)
+ }
+
+ protected val viewEffectObserver = Observer {
+ // DEBUG
+ Log.d(TAG, "observed viewEffect : $it")
+
+ renderViewEffect(it)
+ }
+
+ /**
+ * Consider to start observing viewStates and viewEffects here,
+ * using viewStateObserver and viewEffectObserver.
+ */
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ parent: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return super.onCreateView(inflater, parent, savedInstanceState)
+ }
+
+ abstract fun renderViewState(viewState: STATE)
+
+ abstract fun renderViewEffect(viewEffect: EFFECT)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/design/MviViewModel.kt b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviViewModel.kt
new file mode 100644
index 00000000..77bb29a3
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/design/MviViewModel.kt
@@ -0,0 +1,65 @@
+package com.mydigipay.challenge.presentation.design
+
+import android.app.Application
+import android.util.Log
+import androidx.annotation.CallSuper
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.mydigipay.challenge.common.BaseViewModel
+import com.mydigipay.challenge.common.help.SingleLiveEvent
+
+/**
+ * Reference:
+ * [https://github.com/RohitSurwase/AAC-MVI-Architecture]
+ */
+
+open class MviViewModel(application: Application) :
+ BaseViewModel(application), ViewModelContract {
+
+ private val _viewStates: MutableLiveData = MutableLiveData()
+ fun viewStates(): LiveData = _viewStates
+
+ private var _viewState: STATE? = null
+ protected var viewState: STATE
+ get() = _viewState
+ ?: throw UninitializedPropertyAccessException("\"viewState\" was queried before being initialized")
+ set(value) {
+ Log.d(TAG, "setting viewState : $value")
+ _viewState = value
+ _viewStates.value = value
+ }
+
+
+ private val _viewEffects: SingleLiveEvent = SingleLiveEvent()
+ fun viewEffects(): SingleLiveEvent = _viewEffects
+
+ private var _viewEffect: EFFECT? = null
+ protected var viewEffect: EFFECT
+ get() = _viewEffect
+ ?: throw UninitializedPropertyAccessException("\"viewEffect\" was queried before being initialized")
+ set(value) {
+ Log.d(TAG, "setting viewEffect : $value")
+ _viewEffect = value
+ _viewEffects.value = value
+ }
+
+ @CallSuper
+ override fun process(viewEvent: EVENT) {
+ if (!viewStates().hasObservers()) {
+ throw NoObserverAttachedException(
+ "No observer attached. In case of custom View \"startObserving()\" function needs to be called manually."
+ )
+ }
+
+ // DEBUG
+ Log.d(TAG, "processing viewEvent: $viewEvent")
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+
+ // DEBUG
+ Log.d(TAG, "onCleared")
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/design/Utils.kt b/app/src/main/java/com/mydigipay/challenge/presentation/design/Utils.kt
new file mode 100644
index 00000000..bb611200
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/design/Utils.kt
@@ -0,0 +1,35 @@
+package com.mydigipay.challenge.presentation.design
+
+/**
+ * Reference:
+ * [https://github.com/RohitSurwase/AAC-MVI-Architecture]
+ */
+
+internal val Any.TAG: String
+ get() {
+ return if (!javaClass.isAnonymousClass) {
+ val name = javaClass.simpleName
+ // first 23 chars
+ if (name.length <= 23) name else name.substring(0, 23)
+ }
+ else {
+ val name = javaClass.name
+ // last 23 chars
+ if (name.length <= 23) name else name.substring(name.length - 23, name.length)
+ }
+ }
+
+/**
+ * Internal Contract to be implemented by ViewModel
+ * Required to intercept and log ViewEvents
+ */
+internal interface ViewModelContract {
+ fun process(viewEvent: EVENT)
+}
+
+/**
+ * This is a custom NoObserverAttachedException and it does what it's name suggests.
+ * Constructs a new exception with the specified detail message.
+ * This is thrown, if you have not attached any observer to the LiveData.
+ */
+class NoObserverAttachedException(message: String) : Exception(message)
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/mapper/GithubRepoListItemMapper.kt b/app/src/main/java/com/mydigipay/challenge/presentation/mapper/GithubRepoListItemMapper.kt
new file mode 100644
index 00000000..a44061f5
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/mapper/GithubRepoListItemMapper.kt
@@ -0,0 +1,21 @@
+package com.mydigipay.challenge.presentation.mapper
+
+import com.mydigipay.challenge.mapper.DataMapper
+import com.mydigipay.challenge.presentation.model.ListItem
+import com.mydigipay.challenge.repositories.GithubRepo
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class GithubRepoListItemMapper @Inject constructor() :
+ DataMapper() {
+
+ override fun transformToEntity(model: GithubRepo): ListItem.GithubRepoListItem? {
+ return ListItem.GithubRepoListItem(model)
+ }
+
+ override fun transformToModel(entity: ListItem.GithubRepoListItem): GithubRepo? {
+ // Unnecessary transform
+ return null
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/mapper/README.md b/app/src/main/java/com/mydigipay/challenge/presentation/mapper/README.md
new file mode 100644
index 00000000..5642ce00
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/mapper/README.md
@@ -0,0 +1 @@
+# TODO transform business data models to presentation models, and vice versa
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/model/ListItem.kt b/app/src/main/java/com/mydigipay/challenge/presentation/model/ListItem.kt
new file mode 100644
index 00000000..8c08b2d4
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/model/ListItem.kt
@@ -0,0 +1,15 @@
+package com.mydigipay.challenge.presentation.model
+
+import com.mydigipay.challenge.repositories.GithubRepo
+
+sealed class ListItem(val viewType: ListItemType) {
+
+ data class GithubRepoListItem(val githubRepo: GithubRepo) : ListItem(ListItemType.GITHUB_REPO_ITEM)
+
+ data class LoadingListItem(val page: Int) : ListItem(ListItemType.LOADING_ITEM)
+
+ data class ErrorListItem(
+ val errorMsg: String,
+ val page: Int
+ ) : ListItem(ListItemType.ERROR_ITEM)
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/model/ListItemType.kt b/app/src/main/java/com/mydigipay/challenge/presentation/model/ListItemType.kt
new file mode 100644
index 00000000..34d7623a
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/model/ListItemType.kt
@@ -0,0 +1,18 @@
+package com.mydigipay.challenge.presentation.model
+
+enum class ListItemType(val value: Int) {
+
+ LOADING_ITEM(0), ERROR_ITEM(2), GITHUB_REPO_ITEM(3);
+
+ companion object {
+ fun ofValue(value: Int): ListItemType? {
+ for (userIssue in values()) {
+ if (userIssue.value == value) {
+ return userIssue
+ }
+ }
+
+ return null
+ }
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/model/README.md b/app/src/main/java/com/mydigipay/challenge/presentation/model/README.md
new file mode 100644
index 00000000..20b12f15
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/model/README.md
@@ -0,0 +1 @@
+# TODO define presentation models (User-friendly Information)
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/DividerItemDecoration.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/DividerItemDecoration.kt
new file mode 100644
index 00000000..1b8747ff
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/DividerItemDecoration.kt
@@ -0,0 +1,52 @@
+package com.mydigipay.challenge.presentation.view.adapter
+
+import android.R
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.drawable.Drawable
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ItemDecoration
+
+class DividerItemDecoration : ItemDecoration {
+
+ private var divider: Drawable?
+
+ /**
+ * Default divider will be used
+ */
+ constructor(context: Context) {
+ val styledAttributes = context.obtainStyledAttributes(ATTRS)
+ divider = styledAttributes.getDrawable(0)
+ styledAttributes.recycle()
+ }
+
+ /**
+ * Custom divider will be used
+ */
+ constructor(context: Context?, resId: Int) {
+ divider = ContextCompat.getDrawable(context!!, resId)
+ }
+
+ override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
+ val left = parent.paddingLeft
+ val right = parent.width - parent.paddingRight
+
+ val childCount = parent.childCount
+ for (i in 0 until childCount) {
+ val child = parent.getChildAt(i)
+
+ val params = child.layoutParams as RecyclerView.LayoutParams
+
+ val top = child.bottom + params.bottomMargin
+ val bottom = top + divider!!.intrinsicHeight
+
+ divider!!.setBounds(left, top, right, bottom)
+ divider!!.draw(c)
+ }
+ }
+
+ companion object {
+ private val ATTRS = intArrayOf(R.attr.listDivider)
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/GithubListAdapter.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/GithubListAdapter.kt
new file mode 100644
index 00000000..7a4eac97
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/GithubListAdapter.kt
@@ -0,0 +1,239 @@
+package com.mydigipay.challenge.presentation.view.adapter
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.databinding.DataBindingUtil
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import com.mydigipay.challenge.R
+import com.mydigipay.challenge.databinding.ItemErrorBinding
+import com.mydigipay.challenge.databinding.ItemGithubRepoBinding
+import com.mydigipay.challenge.databinding.ItemLoadingBinding
+import com.mydigipay.challenge.presentation.model.ListItem
+import com.mydigipay.challenge.presentation.model.ListItemType
+import com.mydigipay.challenge.presentation.view.callback.ListItemCallback
+
+/**
+ * Provide a suitable constructor (depends on the kind of dataset)
+ *
+ * @param mCallback
+ */
+class GithubListAdapter(private val mCallback: ListItemCallback) :
+ RecyclerView.Adapter() {
+
+ private var mDataset: MutableList = mutableListOf()
+ private var mLoadingListItem: ListItem.LoadingListItem? = null
+ private var mErrorListItem: ListItem.ErrorListItem? = null
+
+ /**
+ * Create new views (invoked by the layout manager)
+ *
+ * @param parent
+ * @param viewType
+ * @return
+ */
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+ when (viewType) {
+ ListItemType.GITHUB_REPO_ITEM.value -> {
+ val binding: ItemGithubRepoBinding = DataBindingUtil.inflate(
+ LayoutInflater.from(parent.context),
+ R.layout.item_github_repo,
+ parent,
+ false
+ )
+
+ binding.callback = mCallback
+
+ return GithubRepoItemViewHolder(binding)
+ }
+ ListItemType.LOADING_ITEM.value -> {
+ val binding: ItemLoadingBinding = DataBindingUtil.inflate(
+ LayoutInflater.from(parent.context),
+ R.layout.item_loading,
+ parent,
+ false
+ )
+
+ binding.callback = mCallback
+
+ return LoadingItemViewHolder(binding)
+ }
+ else -> {
+ val binding: ItemErrorBinding = DataBindingUtil.inflate(
+ LayoutInflater.from(parent.context),
+ R.layout.item_error,
+ parent,
+ false
+ )
+
+ binding.callback = mCallback
+
+ return ErrorItemViewHolder(binding)
+ }
+ }
+ }
+
+ /**
+ * Replace the contents of a view (invoked by the layout manager)
+ *
+ *
+ * Get element from your dataset at this position and
+ * replace the contents of the view with that element.
+ *
+ * @param holder
+ * @param position
+ */
+ override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
+ when (holder.itemViewType) {
+ ListItemType.GITHUB_REPO_ITEM.value -> {
+ (holder as GithubRepoItemViewHolder).bind(mDataset[position] as ListItem.GithubRepoListItem)
+ }
+ ListItemType.LOADING_ITEM.value -> {
+ (holder as LoadingItemViewHolder).bind(mDataset[position] as ListItem.LoadingListItem)
+ }
+ else -> {
+ (holder as ErrorItemViewHolder).bind(mDataset[position] as ListItem.ErrorListItem)
+ }
+ }
+ }
+
+ /**
+ * Return the size of your dataset (invoked by the layout manager)
+ *
+ * @return
+ */
+ override fun getItemCount(): Int {
+ return mDataset.size
+ }
+
+ /**
+ * Return the view type of the item at `position` for the purposes
+ * of view recycling.
+ *
+ * @return
+ */
+ override fun getItemViewType(position: Int): Int {
+ return mDataset[position].viewType.value
+ }
+
+ /**
+ * Provide a reference to the views for each data item
+ *
+ * Complex data items may need more than one view per item, and
+ * you provide access to all the views for a data item in a view holder.
+ */
+
+ class GithubRepoItemViewHolder(private val binding: ItemGithubRepoBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(githubRepoItem: ListItem.GithubRepoListItem) {
+ with(binding) {
+ item = githubRepoItem
+ executePendingBindings()
+ }
+ }
+ }
+
+ class LoadingItemViewHolder(private val binding: ItemLoadingBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(loadingItem: ListItem.LoadingListItem) {
+ with(binding) {
+ item = loadingItem
+ executePendingBindings()
+ }
+ }
+ }
+
+ class ErrorItemViewHolder(private val binding: ItemErrorBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ fun bind(errorItem: ListItem.ErrorListItem) {
+ with(binding) {
+ item = errorItem
+ executePendingBindings()
+ }
+ }
+ }
+
+ /****************************************************
+ * Functionality to update dataset
+ ***************************************************/
+
+ fun showLoadingItem(loadingItem: ListItem.LoadingListItem) {
+ mLoadingListItem = loadingItem
+ insertItem(loadingItem, mDataset.size)
+ }
+
+ fun hideLoadingItem() {
+ mLoadingListItem?.let {
+ removeItem(it)
+ }
+ mLoadingListItem = null
+ }
+
+ fun showErrorItem(errorItem: ListItem.ErrorListItem) {
+ mErrorListItem = errorItem
+ insertItem(errorItem, mDataset.size)
+ }
+
+ fun hideErrorItem() {
+ mErrorListItem?.let {
+ removeItem(it)
+ }
+ mErrorListItem = null
+ }
+
+ fun addItems(dataset: List) {
+ val beforeSize = mDataset.size
+ mDataset.addAll(dataset)
+ notifyItemRangeInserted(beforeSize, dataset.size)
+ }
+
+ fun clearItems() {
+ mDataset.clear()
+ notifyDataSetChanged()
+ }
+
+ fun insertItem(item: ListItem, position: Int) {
+ mDataset.add(position, item)
+ notifyItemInserted(position)
+ }
+
+ fun removeItem(item: ListItem) {
+ val position = mDataset.indexOf(item)
+ mDataset.removeAt(position)
+ notifyItemRemoved(position)
+ }
+
+ fun changeItem(item: ListItem) {
+ val position = mDataset.indexOf(item)
+ notifyItemChanged(position)
+ }
+
+ fun getDataset(): List? {
+ return mDataset
+ }
+
+ fun changeDataset(dataset: ArrayList) {
+ mDataset = dataset
+ notifyDataSetChanged()
+ }
+
+ fun setDataset(dataset: ArrayList) {
+ if (mDataset.isEmpty()) {
+ mDataset.addAll(dataset)
+ notifyItemRangeInserted(0, dataset.size)
+ }
+ else {
+ val result = DiffUtil.calculateDiff(ListItemDistinguisher(mDataset, dataset))
+
+ mDataset = dataset
+ result.dispatchUpdatesTo(this)
+ }
+ }
+
+ companion object {
+ val TAG = GithubListAdapter::class.java.simpleName
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/ListItemDistinguisher.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/ListItemDistinguisher.kt
new file mode 100644
index 00000000..c95c5b86
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/ListItemDistinguisher.kt
@@ -0,0 +1,52 @@
+package com.mydigipay.challenge.presentation.view.adapter
+
+import androidx.recyclerview.widget.DiffUtil
+import com.mydigipay.challenge.presentation.model.ListItem
+import com.mydigipay.challenge.presentation.model.ListItemType
+
+class ListItemDistinguisher(
+ private val oldSet: MutableList,
+ private val newSet: MutableList
+) : DiffUtil.Callback() {
+
+ override fun getOldListSize(): Int {
+ return oldSet.size
+ }
+
+ override fun getNewListSize(): Int {
+ return newSet.size
+ }
+
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val oldItem = oldSet[oldItemPosition]
+ val newItem = newSet[newItemPosition]
+
+ return if (oldItem.viewType == ListItemType.GITHUB_REPO_ITEM &&
+ newItem.viewType == ListItemType.GITHUB_REPO_ITEM
+ ) {
+ (newItem as ListItem.GithubRepoListItem).githubRepo.id ==
+ (oldItem as ListItem.GithubRepoListItem).githubRepo.id
+ }
+ else {
+ false
+ }
+ }
+
+ override fun areContentsTheSame(
+ oldItemPosition: Int,
+ newItemPosition: Int
+ ): Boolean {
+ val oldItem = oldSet[oldItemPosition]
+ val newItem = newSet[newItemPosition]
+
+ return if (oldItem.viewType == ListItemType.GITHUB_REPO_ITEM &&
+ newItem.viewType == ListItemType.GITHUB_REPO_ITEM
+ ) {
+ return (newItem as ListItem.GithubRepoListItem).githubRepo ==
+ (oldItem as ListItem.GithubRepoListItem).githubRepo
+ }
+ else {
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/VerticalSpaceItemDecoration.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/VerticalSpaceItemDecoration.kt
new file mode 100644
index 00000000..034bdb25
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/adapter/VerticalSpaceItemDecoration.kt
@@ -0,0 +1,20 @@
+package com.mydigipay.challenge.presentation.view.adapter
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ItemDecoration
+
+class VerticalSpaceItemDecoration(private val verticalSpaceHeight: Int) : ItemDecoration() {
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State
+ ) {
+ if (parent.getChildAdapterPosition(view) != parent.adapter!!.itemCount - 1) {
+ outRect.bottom = verticalSpaceHeight
+ }
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/callback/ListItemCallback.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/callback/ListItemCallback.kt
new file mode 100644
index 00000000..2a47fd78
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/callback/ListItemCallback.kt
@@ -0,0 +1,34 @@
+package com.mydigipay.challenge.presentation.view.callback
+
+import android.view.View
+import com.mydigipay.challenge.presentation.model.ListItem
+
+interface ListItemCallback {
+
+ fun onClick(item: ListItem.GithubRepoListItem) {
+ /* Default interface function */
+ }
+
+ fun onLongClick(view: View?, item: ListItem.GithubRepoListItem): Boolean {
+ /* Default interface function */
+ return true
+ }
+
+ fun onClick(item: ListItem.LoadingListItem) {
+ /* Default interface function */
+ }
+
+ fun onLongClick(view: View?, item: ListItem.LoadingListItem): Boolean {
+ /* Default interface function */
+ return true
+ }
+
+ fun onClick(item: ListItem.ErrorListItem) {
+ /* Default interface function */
+ }
+
+ fun onLongClick(view: View?, item: ListItem.ErrorListItem): Boolean {
+ /* Default interface function */
+ return true
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/di/AccessTokenActivityModule.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/AccessTokenActivityModule.kt
new file mode 100644
index 00000000..d6ee7fbd
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/AccessTokenActivityModule.kt
@@ -0,0 +1,19 @@
+package com.mydigipay.challenge.presentation.view.di
+
+import androidx.fragment.app.FragmentManager
+import com.mydigipay.challenge.framework.di.scopes.ActivityScope
+import com.mydigipay.challenge.presentation.view.ui.AccessTokenActivity
+import dagger.Module
+import dagger.Provides
+
+@Module
+class AccessTokenActivityModule {
+
+ @Provides
+ @ActivityScope
+ fun provideFragmentManager(activity: AccessTokenActivity): FragmentManager {
+ return activity.supportFragmentManager
+ }
+
+ /* TODO("Provide other activity dependencies here") */
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/di/GithubReposActivityModule.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/GithubReposActivityModule.kt
new file mode 100644
index 00000000..176b0c06
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/GithubReposActivityModule.kt
@@ -0,0 +1,33 @@
+package com.mydigipay.challenge.presentation.view.di
+
+import androidx.fragment.app.FragmentManager
+import com.mydigipay.challenge.framework.di.scopes.ActivityScope
+import com.mydigipay.challenge.presentation.view.ui.GithubReposActivity
+import com.mydigipay.challenge.presentation.view.ui.GithubReposFragment
+import com.mydigipay.challenge.presentation.view.ui.RepoCommitsFragment
+import dagger.Module
+import dagger.Provides
+
+@Module
+class GithubReposActivityModule {
+
+ @Provides
+ @ActivityScope
+ fun provideFragmentManager(activity: GithubReposActivity): FragmentManager {
+ return activity.supportFragmentManager
+ }
+
+ @Provides
+ @ActivityScope
+ fun provideGithubReposFragment(): GithubReposFragment {
+ return GithubReposFragment.newInstance()
+ }
+
+ @Provides
+ @ActivityScope
+ fun provideRepoCommitsFragment(): RepoCommitsFragment {
+ return RepoCommitsFragment()
+ }
+
+ /* TODO("Provide other activity dependencies here") */
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/di/GithubReposFragmentModule.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/GithubReposFragmentModule.kt
new file mode 100644
index 00000000..79322564
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/GithubReposFragmentModule.kt
@@ -0,0 +1,19 @@
+package com.mydigipay.challenge.presentation.view.di
+
+import androidx.fragment.app.FragmentManager
+import com.mydigipay.challenge.framework.di.scopes.FragmentScope
+import com.mydigipay.challenge.presentation.view.ui.GithubReposFragment
+import dagger.Module
+import dagger.Provides
+
+@Module
+class GithubReposFragmentModule {
+
+ @Provides
+ @FragmentScope
+ fun provideFragmentManager(fragment: GithubReposFragment): FragmentManager {
+ return fragment.childFragmentManager
+ }
+
+ // TODO("Provide other fragment dependencies here...")
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/di/RepoCommitsFragmentModule.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/RepoCommitsFragmentModule.kt
new file mode 100644
index 00000000..741518f3
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/di/RepoCommitsFragmentModule.kt
@@ -0,0 +1,19 @@
+package com.mydigipay.challenge.presentation.view.di
+
+import androidx.fragment.app.FragmentManager
+import com.mydigipay.challenge.framework.di.scopes.FragmentScope
+import com.mydigipay.challenge.presentation.view.ui.RepoCommitsFragment
+import dagger.Module
+import dagger.Provides
+
+@Module
+class RepoCommitsFragmentModule {
+
+ @Provides
+ @FragmentScope
+ fun provideFragmentManager(fragment: RepoCommitsFragment): FragmentManager {
+ return fragment.childFragmentManager
+ }
+
+ // TODO("Provide other fragment dependencies here...")
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/AccessTokenActivity.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/AccessTokenActivity.kt
new file mode 100644
index 00000000..1f163239
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/AccessTokenActivity.kt
@@ -0,0 +1,140 @@
+package com.mydigipay.challenge.presentation.view.ui
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.Menu
+import android.widget.Toast
+import androidx.appcompat.widget.Toolbar
+import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.ViewModelProvider
+import com.mydigipay.challenge.AUTHORIZE_URL
+import com.mydigipay.challenge.REDIRECT_URI
+import com.mydigipay.challenge.CLIENT_ID
+import com.mydigipay.challenge.R
+import com.mydigipay.challenge.databinding.ActivityAccessTokenBinding
+import com.mydigipay.challenge.presentation.design.MviActivity
+import com.mydigipay.challenge.presentation.viewmodel.AccessTokenViewModel
+import com.mydigipay.challenge.presentation.viewstate.*
+import com.mydigipay.challenge.presentation.viewstate.AccessTokenFetchStatus.Fetching
+import javax.inject.Inject
+
+class AccessTokenActivity :
+ MviActivity() {
+
+ /**
+ * Values
+ */
+
+ @Inject
+ internal lateinit var viewModelProviderFactory: ViewModelProvider.Factory
+
+ private lateinit var dataBinding: ActivityAccessTokenBinding
+
+ /**
+ * Workflow
+ */
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+
+ extractIntentParams(intent)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ menuInflater.inflate(R.menu.menu_main, menu)
+
+ return true
+ }
+
+ override fun initializeActivity(savedInstanceState: Bundle?) {
+ // Nothing
+ }
+
+ override fun extractIntentParams(data: Intent?) {
+ data?.let {
+ viewModel.process(AccessTokenViewEvent.NewIntentReceived(it))
+ }
+ }
+
+ override fun setupViews() {
+ // Set Content View
+ dataBinding = DataBindingUtil.setContentView(this, R.layout.activity_access_token)
+
+ dataBinding.content.authorizeButton.setOnClickListener {
+ viewModel.process(AccessTokenViewEvent.AuthorizationButtonClicked)
+ }
+ }
+
+ override fun setupActionBar() {
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ val actionBar = supportActionBar
+ actionBar!!.setDisplayShowTitleEnabled(true)
+ actionBar.setDisplayHomeAsUpEnabled(false)
+ }
+
+ override fun setupNavigation() {
+ // Nothing
+ }
+
+ override fun setupObservers() {
+ super.setupObservers()
+
+ viewModel =
+ ViewModelProvider(this, viewModelProviderFactory).get(AccessTokenViewModel::class.java)
+
+ viewModel.viewStates().observe(this, viewStateObserver)
+ viewModel.viewEffects().observe(this, viewEffectObserver)
+ }
+
+ override fun renderViewState(viewState: AccessTokenViewState) {
+ with(dataBinding.content) {
+ this.loading = viewState.fetchStatus == Fetching
+ executePendingBindings()
+ }
+ }
+
+ override fun renderViewEffect(viewEffect: AccessTokenViewEffect) {
+ when (viewEffect) {
+ is AccessTokenViewEffect.ShowToast -> showToast(viewEffect.message)
+ AccessTokenViewEffect.StartAuthorizationAction -> startAuthorizationAction()
+ AccessTokenViewEffect.NavigateToGithubRepos -> navigateToGithubRepos()
+ else -> throw IllegalArgumentException("Un-expected view effect")
+ }
+ }
+
+ /**
+ * Functionality
+ */
+
+ private fun navigateToGithubRepos() {
+ val i: Intent = Intent(this, GithubReposActivity::class.java)
+ startActivity(i)
+ }
+
+ private fun startAuthorizationAction() {
+ val url = "$AUTHORIZE_URL" +
+ "?client_id=$CLIENT_ID" +
+ "&redirect_uri=$REDIRECT_URI" +
+ "&scope=repo,user" +
+ "&state=0"
+
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.data = Uri.parse(url)
+ startActivity(intent)
+ }
+
+ private fun showToast(message: String) {
+ Toast.makeText(
+ this,
+ message,
+ Toast.LENGTH_LONG
+ ).show()
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/GithubReposActivity.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/GithubReposActivity.kt
new file mode 100644
index 00000000..6406eeae
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/GithubReposActivity.kt
@@ -0,0 +1,93 @@
+package com.mydigipay.challenge.presentation.view.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import androidx.appcompat.widget.Toolbar
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.FragmentManager
+import com.mydigipay.challenge.R
+import com.mydigipay.challenge.common.BaseActivity
+import com.mydigipay.challenge.databinding.ActivityGithubReposBinding
+import javax.inject.Inject
+
+class GithubReposActivity : BaseActivity() {
+
+ /**
+ * Values
+ */
+
+ private lateinit var dataBinding: ActivityGithubReposBinding
+
+ @Inject
+ lateinit var fragmentManager: FragmentManager
+
+ @Inject
+ lateinit var githubReposFragment: GithubReposFragment
+
+ @Inject
+ lateinit var repoCommitsFragment: RepoCommitsFragment
+
+ /**
+ * Workflow
+ */
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+
+ extractIntentParams(intent)
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ menuInflater.inflate(R.menu.menu_main, menu)
+
+ return true
+ }
+
+ override fun initializeActivity(savedInstanceState: Bundle?) {
+ // Nothing
+ }
+
+ override fun extractIntentParams(data: Intent?) {
+ // Nothing
+ }
+
+ override fun setupViews() {
+ // Set Content View
+ dataBinding = DataBindingUtil.setContentView(this, R.layout.activity_github_repos)
+ }
+
+ override fun setupActionBar() {
+ val toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+ val actionBar = supportActionBar
+ actionBar!!.setDisplayShowTitleEnabled(true)
+ actionBar.setDisplayHomeAsUpEnabled(false)
+ }
+
+ override fun setupNavigation() {
+ /* It will be set automatically. */
+ }
+
+ override fun setupObservers() {
+ super.setupObservers()
+
+ // Nothing
+ }
+
+ /****************************************************
+ * SERVICE BINDING
+ ***************************************************/
+
+ // Nothing
+
+ companion object {
+ const val EXTRA_FRAGMENT_ID = "EXTRA_FRAGMENT_ID"
+ const val EXTRA_FRAGMENT_TITLE = "EXTRA_FRAGMENT_TITLE"
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/GithubReposFragment.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/GithubReposFragment.kt
new file mode 100644
index 00000000..fcc0c745
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/GithubReposFragment.kt
@@ -0,0 +1,198 @@
+package com.mydigipay.challenge.presentation.view.ui
+
+import android.os.Bundle
+import android.view.*
+import android.view.animation.AnimationUtils
+import android.widget.TextView
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.ViewModelProvider
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.mydigipay.challenge.R
+import com.mydigipay.challenge.common.help.EndlessScrollListener
+import com.mydigipay.challenge.common.help.runIfNull
+import com.mydigipay.challenge.databinding.FragmentGithubReposBinding
+import com.mydigipay.challenge.presentation.design.MviFragment
+import com.mydigipay.challenge.presentation.model.ListItem
+import com.mydigipay.challenge.presentation.view.adapter.GithubListAdapter
+import com.mydigipay.challenge.presentation.view.adapter.VerticalSpaceItemDecoration
+import com.mydigipay.challenge.presentation.view.callback.ListItemCallback
+import com.mydigipay.challenge.presentation.viewmodel.GithubReposViewModel
+import com.mydigipay.challenge.presentation.viewstate.*
+import com.mydigipay.challenge.presentation.viewstate.GithubReposFetchStatus.*
+import javax.inject.Inject
+
+/**
+ * A simple [Fragment] subclass as the default destination in the navigation.
+ */
+class GithubReposFragment :
+ MviFragment() {
+
+ /**
+ * Values
+ */
+
+ @Inject
+ internal lateinit var viewModelProviderFactory: ViewModelProvider.Factory
+
+ private lateinit var dataBinding: FragmentGithubReposBinding
+ private lateinit var githubReposAdapter: GithubListAdapter
+
+ /**
+ * Workflow
+ */
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ viewModel = ViewModelProvider(requireActivity(), viewModelProviderFactory)[GithubReposViewModel::class.java]
+ savedInstanceState.runIfNull {
+ /* Nothing */
+ }
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ parent: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ dataBinding =
+ DataBindingUtil.inflate(inflater, R.layout.fragment_github_repos, parent, false)
+
+ return dataBinding.root
+ }
+
+ override fun onContextItemSelected(item: MenuItem): Boolean {
+ return false
+ }
+
+ override fun setupViews() {
+ setupItemsListView()
+ setupErrorAnnounce()
+ }
+
+ override fun setupItemsListView() {
+ githubReposAdapter = GithubListAdapter(mCallback)
+
+ val linearLayoutManager = LinearLayoutManager(context)
+ dataBinding.githubRepos.apply {
+ adapter = githubReposAdapter
+ layoutManager = linearLayoutManager
+ addItemDecoration(VerticalSpaceItemDecoration(1))
+ addOnScrollListener(object : EndlessScrollListener(linearLayoutManager) {
+ override fun onLoadMore(page: Int) {
+ viewModel.process(GithubReposViewEvent.OnLoadMore(page))
+ }
+ })
+ }
+
+ dataBinding.swipeContainer.setOnRefreshListener {
+ viewModel.process(GithubReposViewEvent.OnSwipeRefresh)
+ }
+ }
+
+ override fun setupErrorAnnounce() {
+ dataBinding.errorAnnounce.setFactory {
+ val t = TextView(context)
+ t.gravity = Gravity.CENTER_VERTICAL or Gravity.CENTER_HORIZONTAL
+ t.setTextColor(context!!.resources.getColor(R.color.colorTextDescription))
+ t
+ }
+ dataBinding.errorAnnounce.inAnimation = AnimationUtils.loadAnimation(
+ context,
+ R.anim.slide_in_down
+ )
+ dataBinding.errorAnnounce.outAnimation = AnimationUtils.loadAnimation(
+ context,
+ R.anim.slide_out_up
+ )
+ dataBinding.errorAnnounce.setCurrentText("")
+ }
+
+ override fun setupObservers() {
+ super.setupObservers()
+
+ viewModel.viewStates().observe(viewLifecycleOwner, viewStateObserver)
+ viewModel.viewEffects().observe(viewLifecycleOwner, viewEffectObserver)
+
+ viewModel.process(GithubReposViewEvent.OnLoadMore(FIRST_PAGE))
+ }
+
+ override fun renderViewState(viewState: GithubReposViewState) {
+ with(dataBinding) {
+ this.initialLoading = viewState.fetchStatus === InitialPageFetching
+ this.initialError = viewState.fetchStatus is InitialPageNotFetched
+ executePendingBindings()
+ }
+
+ // Handle showing loading indicator for sequence pages
+ when(viewState.fetchStatus) {
+ InitialPageFetching -> {
+ /* Nothing */
+ }
+ InitialPageFetched -> {
+ githubReposAdapter.setDataset(viewState.githubRepos as ArrayList)
+ }
+ is InitialPageNotFetched -> {
+ dataBinding.errorAnnounce.setText(viewState.fetchStatus.errorMessage)
+ }
+ SequencePageFetching -> {
+ githubReposAdapter.showLoadingItem(buildLoadingItem(viewState.page))
+ }
+ SequencePageFetched -> {
+ githubReposAdapter.hideLoadingItem()
+ githubReposAdapter.setDataset(viewState.githubRepos as ArrayList)
+ }
+ is SequencePageNotFetched -> {
+ githubReposAdapter.hideLoadingItem()
+ githubReposAdapter.showErrorItem(buildErrorItem(viewState.fetchStatus.errorMessage, viewState.page))
+ }
+ }
+ }
+
+ override fun renderViewEffect(viewEffect: GithubReposViewEffect) {
+ when(viewEffect) {
+ is GithubReposViewEffect.NavigateToRepoCommits -> {
+ findNavController().navigate(R.id.action_GithubReposFragment_to_RepoCommitsFragment)
+ }
+ else -> throw IllegalArgumentException("Un-expected view effect")
+ }
+ }
+
+ /**
+ * Products Adapter Callback
+ */
+ private val mCallback: ListItemCallback by lazy {
+ object : ListItemCallback {
+ override fun onClick(item: ListItem.GithubRepoListItem) {
+ if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ viewModel.process(GithubReposViewEvent.GithubRepoClicked(item))
+ }
+ }
+
+ override fun onClick(item: ListItem.ErrorListItem) {
+ if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ viewModel.process(GithubReposViewEvent.OnLoadMore(item.page))
+ }
+ }
+ }
+ }
+
+ companion object {
+
+ /**
+ * Use this factory method to create a new instance of this fragment.
+ *
+ * @return A new instance of fragment [GithubReposFragment].
+ */
+ fun newInstance(): GithubReposFragment {
+ return GithubReposFragment()
+ }
+
+ val TAG = GithubReposFragment::class.java.simpleName
+ val ID = GithubReposFragment::class.java.simpleName.hashCode()
+ const val TITLE: Int = R.string.github_repos_fragment_label
+ }
+}
diff --git a/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/RepoCommitsFragment.kt b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/RepoCommitsFragment.kt
new file mode 100644
index 00000000..81aa572b
--- /dev/null
+++ b/app/src/main/java/com/mydigipay/challenge/presentation/view/ui/RepoCommitsFragment.kt
@@ -0,0 +1,32 @@
+package com.mydigipay.challenge.presentation.view.ui
+
+import android.os.Bundle
+import androidx.fragment.app.Fragment
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import androidx.navigation.fragment.findNavController
+import com.mydigipay.challenge.R
+
+/**
+ * A simple [Fragment] subclass as the second destination in the navigation.
+ */
+class RepoCommitsFragment : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ // Inflate the layout for this fragment
+ return inflater.inflate(R.layout.fragment_repo_commits, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ view.findViewById