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