├── .gitignore
├── README.md
├── app
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── kotlin
│ │ └── com
│ │ └── globant
│ │ └── myapplication
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── globant
│ │ │ ├── SampleApplication.kt
│ │ │ ├── activities
│ │ │ └── MainActivity.kt
│ │ │ ├── di
│ │ │ └── KoinModules.kt
│ │ │ ├── utils
│ │ │ ├── Constants.kt
│ │ │ └── Data.kt
│ │ │ └── viewmodels
│ │ │ ├── CharacterViewModel.kt
│ │ │ └── base
│ │ │ └── BaseViewModel.kt
│ └── res
│ │ ├── drawable-v24
│ │ └── ic_launcher_foreground.xml
│ │ ├── drawable
│ │ └── ic_launcher_background.xml
│ │ ├── layout
│ │ └── activity_main.xml
│ │ ├── mipmap-anydpi-v26
│ │ ├── ic_launcher.xml
│ │ └── ic_launcher_round.xml
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.png
│ │ └── ic_launcher_round.png
│ │ └── values
│ │ ├── colors.xml
│ │ ├── strings.xml
│ │ └── styles.xml
│ └── test
│ └── java
│ └── com
│ └── globant
│ └── myapplication
│ ├── CharacterViewModelTest.kt
│ ├── ExampleUnitTest.kt
│ └── util
│ └── LiveDataTestExtensions.kt
├── build.gradle
├── data
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── kotlin
│ │ └── com
│ │ └── globant
│ │ └── data
│ │ └── ExampleInstrumentedTest.java
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── globant
│ │ │ └── data
│ │ │ ├── MarvelRequestGenerator.kt
│ │ │ ├── Utils.kt
│ │ │ ├── database
│ │ │ ├── CharacterDatabase.kt
│ │ │ ├── entity
│ │ │ │ └── MarvelCharacterRealm.kt
│ │ │ └── response
│ │ │ │ └── DataBaseResponse.kt
│ │ │ ├── mapper
│ │ │ ├── BaseMapperRepository.kt
│ │ │ ├── CharacterMapperLocal.kt
│ │ │ └── CharacterMapperService.kt
│ │ │ ├── repositories
│ │ │ └── MarvelCharacterRepositoryImpl.kt
│ │ │ └── service
│ │ │ ├── CharacterService.kt
│ │ │ ├── api
│ │ │ └── MarvelApi.kt
│ │ │ └── response
│ │ │ ├── CharacterResponse.kt
│ │ │ └── MarvelBaseResponse.kt
│ └── res
│ │ └── values
│ │ └── strings.xml
│ └── test
│ └── java
│ └── com
│ └── globant
│ └── data
│ └── ExampleUnitTest.java
├── di
├── .gitignore
├── build.gradle
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── com
│ │ └── globant
│ │ └── di
│ │ └── ExampleInstrumentedTest.java
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── com
│ │ │ └── globant
│ │ │ └── KoinModules.kt
│ └── res
│ │ └── values
│ │ └── strings.xml
│ └── test
│ └── java
│ └── com
│ └── globant
│ └── di
│ └── ExampleUnitTest.java
├── domain
├── .gitignore
├── build.gradle
└── src
│ └── main
│ └── java
│ └── com
│ └── globant
│ └── domain
│ ├── entities
│ └── MarvelCharacter.kt
│ ├── repositories
│ └── MarvelCharacterRepository.kt
│ ├── usecases
│ └── GetCharacterByIdUseCase.kt
│ └── utils
│ └── Result.kt
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── settings.gradle
/.gitignore:
--------------------------------------------------------------------------------
1 | /captures
2 | .externalNativeBuild
3 |
4 | gradle.properties
5 |
6 | # Built application files
7 | *.apk
8 | *.ap_
9 |
10 | # Files for the Dalvik VM
11 | *.dex
12 |
13 | # Java class files
14 | *.class
15 |
16 | # Make files
17 | *.mk
18 |
19 | # Generated files
20 | bin/
21 | gen/
22 | out/
23 |
24 | # Gradle files
25 | .gradle/
26 | gradlew.bat
27 | gradlew
28 | build/
29 | gradle.properties
30 |
31 | # Local configuration file (sdk path, etc)
32 | local.properties
33 |
34 | # Proguard folder generated by Eclipse
35 | proguard/
36 |
37 | # Log Files
38 | *.log
39 |
40 | # Android Studio Navigation editor temp files
41 | .navigation/
42 |
43 | # Android Studio captures folder
44 | captures/
45 |
46 | # Android Studio & IntelliJ
47 | *.iws
48 | .idea/
49 | .idea/tasks.xml
50 | .idea/workspace.xml
51 | .idea/libraries
52 | *.iml
53 |
54 | # Keystore files
55 | *.jks
56 |
57 | # OS
58 | .DS_Store
59 | .navigation/
60 |
61 | ### Android Patch ###
62 | gen-external-apklibs
63 |
64 | # Thumbnails
65 | ._*
66 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Kotlin + MVVM + Clean Architecture + Coroutines + Koin
2 |
3 | This project was made with the objective of creating a base structure for new apps, using tools and components supported by Google and by most of the Android development community.
4 |
5 |
6 | ## Clean Architecture
7 |
8 | Clean architecture promotes separation of concerns, making the code loosely coupled. This results in a more testable and flexible code. This approach divides the project in 3 modules: presentation, data and domain.
9 |
10 | * __Presentation__: Layer with the Android Framework, the MVVM pattern and the DI module. Depends on domain to access the use cases and on di, to inject dependencies.
11 | * __Domain__: Layer with the business logic. Contains the use cases, in charge of calling the correct repository or data member.
12 | * __Data__: Layer with the responsibility of selecting the proper data source for the domain layer. It contains the implementations of the repositories declared in the domain layer. It may, for example, check if the data in a database is up to date, and retrieve it from a service if it’s not.
13 |
14 | As there isn’t a single way to implement Clean Architecture, this could affront changes in the future.
15 |
16 | ### Domain
17 |
18 | Business logic can be defined as the core operations done by the application. The domain tries to encapsulate this business logic, to make it agnostic of its context. The components of the domain are:
19 |
20 | * __Entities__: Simple classes that represent the objects in which the business is based.
21 | * __Repositories__: Interfaces used by the use cases. Implemented in the data layer.
22 | * __Use cases__: Also called interactors. They enclose a single action, like getting data from a database or posting to a service. They use the repositories to resolve the action they are supposed to do. They usually override the operator “invoke”, so they can be called as a function.
23 |
24 | ### Data
25 |
26 | The data layer is the implementation of all the repositories declared by the domain layer. This acts as a support of the business layer, from where it obtains the data needed to be shown in the UI.
27 |
28 | Data is also an Android module so, besides databases and network requests, it can provide locations, bluetooth access, gyroscope data, among other information respective to the device. This could be separated in another module to provide independency from the framework.
29 |
30 | It’s the repository job to know what should be the source of the data. The repository should decide whether the data in the local database is good enough or if it should pull it from a service. The repository shouldn’t be tied to an implementation of database/services. It should have references to interfaces that access the actual framework. A boolean may be passed as a parameter to the repository to force an update from a specific source.
31 |
32 | ### Presentation
33 |
34 | Presentation layer contains every component involved in showing information to the user. The main part of this layer are the Views and ViewModels that will be explained in the next section. In general, the presentation layer is the one using all the Use Cases/Interactors that we created in the domain layer.
35 |
36 | Views in this layer are the fragments and activities designed to show information to the user. In MVVM, these views are separated from the logic, which is encapsulated in the ViewModel.
37 |
38 | ## MVVM
39 |
40 | The Model View ViewModel pattern helps with the separation of concerns, dividing the user interface with the logic behind. The decision to use this pattern is mainly based on the support Google has been giving to it. Not only they have created a ViewModel class to use as a parent to the viewmodels, there is also a huge use of the pattern in official Android presentations and samples. Moreover, MVVM is vastly used in today’s Android development, and combines very well with Android Architecture Components like LiveData and DataBindings.
41 |
42 | ### Model
43 |
44 | As we are implementing MVVM alongside with Clean Architecture, we decided not to have a model class per se. The ViewModel interacts directly with the domain, utilizing the use cases.
45 |
46 | ### View Model
47 |
48 | The orchestrator of the relationship between the data and the user interface of the application. The ViewModel has the logic to convert what the use cases provide into information that the view can understand and present. Furthermore, it has the logic to react to the user’s input, and call the pertinent use cases.
49 |
50 | The most useful part of the Android’s ViewModel class is its lifecycle consciousness. It only communicates to the View with LiveData components, so it’s totally agnostic of contexts and activities: it can keep the information alive even against configuration changes like screen rotations or calls to background.
51 |
52 | ### View
53 |
54 | The view in our implementation of MVVM is actually a Fragment or an Activity. The views enclose everything needed to handle the user interface. They observe the ViewModel, using LiveData components, and react to its changes as they need to.
55 |
56 | ### LiveData Architecture Component
57 |
58 | The view uses LiveData to observe changes in the ViewModel. This has several advantages:
59 |
60 | * The UI matches the data state, and this keeps data up to date.
61 | * Not having to worry about stopped activities and memory leaks. Live data objects are subscript to a lifecycle and automatically stop observing when that lifecycle is ended.
62 | * Handles configuration changes properly.
63 | * The same data could be shared between activities.
64 |
65 | ## Dependency Injection with Koin
66 |
67 | Dependency injection is closely related to two SOLID concepts: dependency inversion, which states that high level modules should not depend on low level modules, both should depend on abstractions; and single responsibility principle, which states that every class or module is responsible for just a single piece of functionality.
68 | DI supports these goals by decoupling the creation and the usage of an object. It allows you to replace dependencies without changing the class that uses them and also reduces the risk of modifying a class because one of its dependencies changed.
69 | This sample app uses Koin as the dependency injection library.
70 |
71 | Koin is one of the most popular dependency injection frameworks for Android. It’s written purely in Kotlin, it’s lightweight and it’s easier to learn than its competition. It works in three simple steps:
72 |
73 | 1. __Declare a module__: Defines those entities which will be injected at some point in the app.
74 |
75 | ```
76 | val applicationModule = module {
77 | single { AppRepository }
78 | }
79 | ```
80 |
81 | 2. __Start Koin__: A single line, startKoin(this, listOf(applicationModule)), allows you to launch the DI process and indicate which modules will be available when needed, in this case, only applicationModule.
82 |
83 | ```
84 | class BaseApplication : Application() {
85 | override fun onCreate() {
86 | super.onCreate()
87 | startKoin(this, listOf(applicationModule))
88 | }
89 | }
90 | ```
91 |
92 | 3. __Perform an injection__:
93 | In consonance with Kotlin features, Koin allows to perform lazy injections in a very convenient way.
94 |
95 | ```
96 | class FeatureActivity : AppCompatActivity() {
97 | private val appRepository: AppRepository by inject()
98 | ...
99 | }
100 | ```
101 |
102 | ## Coroutines
103 |
104 | Coroutines are a new way of managing background threads that can simplify code by reducing the need for callbacks. They convert async callbacks for long-running tasks, such as database or network access, into sequential code.
105 | We use coroutines to do tasks in a background thread. This goes very well with the idea of use cases, single actions that the ViewModel calls depending of its needs. The guideline should be that every task executed by a use case should be done in a background thread, so, in the main thread, we could show a loading screen or any alternative, and the UI doesn’t get blocked.
106 |
107 | __Job__: a job is a cancellable task with a life-cycle that culminates in its completion. By default, a failure of any of the job’s children leads to an immediate failure of its parent and cancellation of the rest of its children. This behavior can be customized using SupervisorJob.
108 |
109 | __Dispatchers__:
110 | * Dispatchers.Default – is used by all standard builders by default. It uses a common pool of shared background threads. This is an appropriate choice for compute-intensive coroutines that consume CPU resources.
111 | * Dispatchers.IO – uses a shared pool of on-demand created threads and is designed for offloading of IO-intensive blocking operations (like file I/O and blocking socket I/O).
112 |
113 | ## Other concepts
114 |
115 | ### AndroidX
116 |
117 | AndroidX is a redesigned library, replacing the support library, to make package names more clear. Each androidx package has it own version, detached from the Android API version, so extension libraries can be developed independently.
118 |
119 | Androidx also improves understanding of what is added to the app:
120 |
121 | * android.* -> bundled in the platform.
122 | * androidx.* -> extension library.
123 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.application'
2 | apply plugin: 'kotlin-android'
3 | apply plugin: 'kotlin-android-extensions'
4 | apply plugin: 'kotlin-kapt'
5 | apply plugin: 'realm-android'
6 |
7 | buildscript {
8 | repositories {
9 | jcenter()
10 | }
11 | dependencies {
12 | classpath "io.realm:realm-gradle-plugin:5.11.0"
13 | }
14 | }
15 |
16 | realm {
17 | syncEnabled = true;
18 | }
19 | android {
20 | compileSdkVersion 28
21 | defaultConfig {
22 | applicationId "com.globant.myapplication"
23 | minSdkVersion 17
24 | targetSdkVersion 28
25 | versionCode 1
26 | versionName "1.0"
27 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
28 | }
29 | buildTypes {
30 | release {
31 | minifyEnabled false
32 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
33 | }
34 | }
35 | }
36 |
37 | dependencies {
38 | implementation fileTree(dir: 'libs', include: ['*.jar'])
39 | implementation project(path: ':domain')
40 | implementation project(path: ':di')
41 |
42 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
43 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1'
44 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1'
45 | implementation 'androidx.appcompat:appcompat:1.0.2'
46 | implementation 'androidx.core:core-ktx:1.0.2'
47 | implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
48 | implementation "org.koin:koin-androidx-viewmodel:2.0.0-GA"
49 |
50 | testImplementation 'junit:junit:4.12'
51 | androidTestImplementation 'androidx.test:runner:1.1.1'
52 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
53 |
54 | testImplementation "com.google.truth:truth:0.42"
55 | testImplementation 'org.mockito:mockito-core:2.27.0'
56 | testImplementation 'org.koin:koin-test:2.0.0-GA'
57 |
58 | def lifecycle_version = '2.0.0-beta01'
59 | testImplementation "androidx.arch.core:core-testing:$lifecycle_version"
60 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1'
61 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1'
62 | testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.1'
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/app/src/androidTest/kotlin/com/globant/myapplication/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.globant.myapplication
2 |
3 | import androidx.test.InstrumentationRegistry
4 | import androidx.test.runner.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getTargetContext()
22 | assertEquals("com.globant.myapplication", appContext.packageName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/SampleApplication.kt:
--------------------------------------------------------------------------------
1 | package com.globant
2 |
3 | import android.app.Application
4 | import com.globant.di.useCasesModule
5 | import com.globant.di.viewModelsModule
6 | import io.realm.Realm
7 | import org.koin.core.context.startKoin
8 |
9 | class SampleApplication: Application() {
10 | override fun onCreate() {
11 | super.onCreate()
12 | Realm.init(this)
13 |
14 | startKoin {
15 | modules(listOf(repositoriesModule, viewModelsModule, useCasesModule))
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/activities/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.globant.activities
2 |
3 | import android.os.Bundle
4 | import android.view.View
5 | import android.widget.Toast
6 | import androidx.appcompat.app.AppCompatActivity
7 | import com.globant.domain.entities.MarvelCharacter
8 | import com.globant.utils.Data
9 | import com.globant.utils.Status
10 | import com.globant.viewmodels.CharacterViewModel
11 | import com.globant.myapplication.R
12 | import com.globant.utils.MINUS_ONE
13 | import kotlinx.android.synthetic.main.activity_main.*
14 | import org.koin.androidx.viewmodel.ext.android.viewModel
15 |
16 | class MainActivity : AppCompatActivity() {
17 |
18 | private val viewModel by viewModel()
19 |
20 | override fun onCreate(savedInstanceState: Bundle?) {
21 | super.onCreate(savedInstanceState)
22 | setContentView(R.layout.activity_main)
23 |
24 | viewModel.mainState.observe(::getLifecycle, ::updateUI)
25 |
26 | buttonSearchRemote.setOnClickListener { onSearchRemoteClicked() }
27 | buttonSearchLocal.setOnClickListener { onSearchLocalClicked() }
28 | }
29 |
30 | private fun updateUI(characterData: Data) {
31 | when (characterData.responseType) {
32 | Status.ERROR -> {
33 | hideProgress()
34 | characterData.error?.message?.let { showMessage(it) }
35 | characterData.data?.let { setCharacter(it) }
36 | }
37 | Status.LOADING -> {
38 | showProgress()
39 | }
40 | Status.SUCCESSFUL -> {
41 | hideProgress()
42 | characterData.data?.let { setCharacter(it) }
43 | }
44 | }
45 | }
46 |
47 | private fun showProgress() {
48 | progress.visibility = View.VISIBLE
49 | textViewDetails.visibility = View.GONE
50 | }
51 |
52 | private fun hideProgress() {
53 | progress.visibility = View.GONE
54 | textViewDetails.visibility = View.VISIBLE
55 | }
56 |
57 | private fun setCharacter(character: MarvelCharacter) {
58 | textViewDetails.text = character.description
59 | }
60 |
61 | private fun showMessage(message: String) {
62 | Toast.makeText(this, message, Toast.LENGTH_LONG).show()
63 | }
64 |
65 | private fun onSearchRemoteClicked() {
66 | val id = if (characterID.text.toString().isNotEmpty()) {
67 | characterID.text.toString().toInt()
68 | } else {
69 | MINUS_ONE
70 | }
71 | viewModel.onSearchRemoteClicked(id)
72 | }
73 | private fun onSearchLocalClicked() {
74 | val id = if (characterID.text.toString().isNotEmpty()) {
75 | characterID.text.toString().toInt()
76 | } else {
77 | MINUS_ONE
78 | }
79 | viewModel.onSearchLocalClicked(id)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/di/KoinModules.kt:
--------------------------------------------------------------------------------
1 | package com.globant.di
2 |
3 | import com.globant.domain.usecases.GetCharacterByIdUseCase
4 | import com.globant.viewmodels.CharacterViewModel
5 | import org.koin.androidx.viewmodel.dsl.viewModel
6 | import org.koin.dsl.module
7 |
8 | val viewModelsModule = module {
9 | viewModel { CharacterViewModel(get()) }
10 | }
11 |
12 | val useCasesModule = module {
13 | single { GetCharacterByIdUseCase() }
14 | }
15 |
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/utils/Constants.kt:
--------------------------------------------------------------------------------
1 | package com.globant.utils
2 |
3 | const val MINUS_ONE = -1
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/utils/Data.kt:
--------------------------------------------------------------------------------
1 | package com.globant.utils
2 |
3 | /**
4 | * A generic wrapper class around data request
5 | */
6 | data class Data(var responseType: Status, var data: RequestData? = null, var error: Exception? = null)
7 |
8 | enum class Status { SUCCESSFUL, ERROR, LOADING }
9 |
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/viewmodels/CharacterViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.globant.viewmodels
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.MutableLiveData
5 | import com.globant.domain.entities.MarvelCharacter
6 | import com.globant.domain.usecases.GetCharacterByIdUseCase
7 | import com.globant.domain.utils.Result
8 | import com.globant.utils.Data
9 | import com.globant.utils.Status
10 | import com.globant.viewmodels.base.BaseViewModel
11 | import kotlinx.coroutines.Dispatchers
12 | import kotlinx.coroutines.launch
13 | import kotlinx.coroutines.withContext
14 |
15 | class CharacterViewModel(val getCharacterById: GetCharacterByIdUseCase) : BaseViewModel() {
16 |
17 | private var mutableMainState: MutableLiveData> = MutableLiveData()
18 | val mainState: LiveData>
19 | get() {
20 | return mutableMainState
21 | }
22 |
23 | fun onSearchRemoteClicked(id: Int) = launch {
24 | mutableMainState.value = Data(responseType = Status.LOADING)
25 | when (val result = withContext(Dispatchers.IO) { getCharacterById(id, true) }) {
26 | is Result.Failure -> {
27 | mutableMainState.value = Data(responseType = Status.ERROR, error = result.exception)
28 | }
29 | is Result.Success -> {
30 | mutableMainState.value = Data(responseType = Status.SUCCESSFUL, data = result.data)
31 | }
32 | }
33 | }
34 |
35 | fun onSearchLocalClicked(id: Int) = launch {
36 | mutableMainState.value = Data(responseType = Status.LOADING)
37 | when (val result = withContext(Dispatchers.IO) { getCharacterById(id, false) }) {
38 | is Result.Failure -> {
39 | mutableMainState.value = Data(responseType = Status.ERROR, error = result.exception)
40 | }
41 | is Result.Success -> {
42 | mutableMainState.value = Data(responseType = Status.SUCCESSFUL, data = result.data)
43 | }
44 | }
45 | }
46 | }
47 |
48 |
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/globant/viewmodels/base/BaseViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.globant.viewmodels.base
2 |
3 | import androidx.lifecycle.ViewModel
4 | import kotlinx.coroutines.CoroutineScope
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.SupervisorJob
7 | import kotlinx.coroutines.cancel
8 | import kotlin.coroutines.CoroutineContext
9 |
10 | open class BaseViewModel : ViewModel(), CoroutineScope {
11 | override val coroutineContext: CoroutineContext
12 | get() = Dispatchers.Main + SupervisorJob()
13 |
14 | override fun onCleared() {
15 | super.onCleared()
16 | coroutineContext.cancel()
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
13 |
19 |
22 |
25 |
26 |
27 |
28 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
10 |
12 |
14 |
16 |
18 |
20 |
22 |
24 |
26 |
28 |
30 |
32 |
34 |
36 |
38 |
40 |
42 |
44 |
46 |
48 |
50 |
52 |
54 |
56 |
58 |
60 |
62 |
64 |
66 |
68 |
70 |
72 |
74 |
75 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
17 |
18 |
25 |
26 |
36 |
37 |
40 |
41 |
48 |
49 |
56 |
57 |
58 |
59 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #008577
4 | #00574B
5 | #D81B60
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Android Architecture Sample
3 | insert character ID
4 | Search remote
5 | NO CHARACTER
6 | Search local
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/test/java/com/globant/myapplication/CharacterViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.globant.myapplication
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import androidx.lifecycle.LiveData
5 | import androidx.lifecycle.Observer
6 | import com.globant.di.useCasesModule
7 | import com.globant.domain.entities.MarvelCharacter
8 | import com.globant.domain.usecases.GetCharacterByIdUseCase
9 | import com.globant.domain.utils.Result
10 | import com.globant.utils.Data
11 | import com.globant.utils.Status
12 | import com.globant.viewmodels.CharacterViewModel
13 | import com.google.common.truth.Truth
14 | import kotlinx.coroutines.*
15 | import kotlinx.coroutines.test.resetMain
16 | import kotlinx.coroutines.test.setMain
17 | import org.junit.After
18 | import org.junit.Before
19 | import org.junit.Rule
20 | import org.junit.Test
21 | import org.koin.core.context.startKoin
22 | import org.koin.core.context.stopKoin
23 | import org.koin.test.AutoCloseKoinTest
24 | import org.koin.test.inject
25 | import org.koin.test.mock.declareMock
26 | import org.mockito.Mock
27 | import org.mockito.MockitoAnnotations
28 | import java.lang.Exception
29 | import org.mockito.Mockito.`when` as whenever
30 |
31 | private const val VALID_ID = 1017100
32 | private const val INVALID_ID = -1
33 |
34 | class CharacterViewModelTest : AutoCloseKoinTest() {
35 |
36 | @ObsoleteCoroutinesApi
37 | private var mainThreadSurrogate = newSingleThreadContext("UI thread")
38 |
39 | @get:Rule
40 | val instantTaskExecutorRule = InstantTaskExecutorRule()
41 |
42 | lateinit var subject: CharacterViewModel
43 | @Mock lateinit var marvelCharacterValidResult: Result.Success
44 | @Mock lateinit var marvelCharacterInvalidResult: Result.Failure
45 | @Mock lateinit var marvelCharacter: MarvelCharacter
46 | @Mock lateinit var exception: Exception
47 |
48 | private val getCharacterByIdUseCase: GetCharacterByIdUseCase by inject()
49 |
50 | @ExperimentalCoroutinesApi
51 | @ObsoleteCoroutinesApi
52 | @Before
53 | fun setup() {
54 | Dispatchers.setMain(mainThreadSurrogate)
55 | startKoin {
56 | modules(listOf(useCasesModule))
57 | }
58 |
59 | declareMock()
60 | MockitoAnnotations.initMocks(this)
61 | subject = CharacterViewModel(getCharacterByIdUseCase)
62 | }
63 |
64 | @ExperimentalCoroutinesApi
65 | @ObsoleteCoroutinesApi
66 | @After
67 | fun after() {
68 | stopKoin()
69 | mainThreadSurrogate.close()
70 | Dispatchers.resetMain()
71 | }
72 |
73 | @Test
74 | fun onSearchRemoteTestSuccessful() {
75 | val liveDataUnderTest = subject.mainState.testObserver()
76 | whenever(getCharacterByIdUseCase.invoke(VALID_ID, true)).thenReturn(marvelCharacterValidResult)
77 | whenever(marvelCharacterValidResult.data).thenReturn(marvelCharacter)
78 | runBlocking {
79 | subject.onSearchRemoteClicked(VALID_ID).join()
80 | }
81 | Truth.assertThat(liveDataUnderTest.observedValues)
82 | .isEqualTo(listOf(Data(Status.LOADING), Data(Status.SUCCESSFUL, data = marvelCharacter)))
83 |
84 | }
85 |
86 | @Test
87 | fun onSearchRemoteTestError() {
88 | val liveDataUnderTest = subject.mainState.testObserver()
89 | whenever(getCharacterByIdUseCase.invoke(INVALID_ID, true)).thenReturn(marvelCharacterInvalidResult)
90 | whenever(marvelCharacterInvalidResult.exception).thenReturn(exception)
91 |
92 | runBlocking {
93 | subject.onSearchRemoteClicked(INVALID_ID).join()
94 | }
95 |
96 | Truth.assertThat(liveDataUnderTest.observedValues)
97 | .isEqualTo(listOf(Data(Status.LOADING), Data(Status.ERROR, data = null, error = exception)))
98 | }
99 |
100 | @Test
101 | fun onSearchLocalSuccessful() {
102 | val liveDataUnderTest = subject.mainState.testObserver()
103 | whenever(getCharacterByIdUseCase.invoke(VALID_ID, false)).thenReturn(marvelCharacterValidResult)
104 | whenever(marvelCharacterValidResult.data).thenReturn(marvelCharacter)
105 |
106 | runBlocking {
107 | subject.onSearchLocalClicked(VALID_ID).join()
108 | }
109 |
110 | Truth.assertThat(liveDataUnderTest.observedValues)
111 | .isEqualTo(listOf(Data(Status.LOADING), Data(Status.SUCCESSFUL, data = marvelCharacter)))
112 | }
113 |
114 | @Test
115 | fun onSearchLocalTestError() {
116 | val liveDataUnderTest = subject.mainState.testObserver()
117 | whenever(getCharacterByIdUseCase.invoke(INVALID_ID, true)).thenReturn(marvelCharacterInvalidResult)
118 | whenever(marvelCharacterInvalidResult.exception).thenReturn(exception)
119 |
120 | runBlocking {
121 | subject.onSearchRemoteClicked(INVALID_ID).join()
122 | }
123 |
124 | Truth.assertThat(liveDataUnderTest.observedValues)
125 | .isEqualTo(listOf(Data(Status.LOADING), Data(Status.ERROR, data = null, error = exception)))
126 | }
127 |
128 | class TestObserver : Observer {
129 | val observedValues = mutableListOf()
130 | override fun onChanged(value: T?) {
131 | observedValues.add(value)
132 | }
133 | }
134 |
135 | private fun LiveData.testObserver() = TestObserver().also {
136 | observeForever(it)
137 | }
138 |
139 | }
140 |
--------------------------------------------------------------------------------
/app/src/test/java/com/globant/myapplication/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.globant.myapplication
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/test/java/com/globant/myapplication/util/LiveDataTestExtensions.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2018 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * https://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package com.globant.myapplication.util
18 |
19 | import androidx.lifecycle.LiveData
20 | import androidx.lifecycle.Observer
21 | import com.google.common.truth.Truth
22 | import kotlinx.coroutines.TimeoutCancellationException
23 | import kotlinx.coroutines.channels.Channel
24 | import kotlinx.coroutines.withTimeout
25 |
26 | /**
27 | * Represents a list of capture values from a LiveData.
28 | *
29 | * This class is not threadsafe and must be used from the main thread.
30 | */
31 | class LiveDataValueCapture {
32 |
33 | private val _values = mutableListOf()
34 | val values: List
35 | get() = _values
36 |
37 | val channel = Channel(Channel.UNLIMITED)
38 |
39 | fun addValue(value: T?) {
40 | _values += value
41 | channel.offer(value)
42 | }
43 |
44 | suspend fun assertSendsValues(timeout: Long, vararg expected: T?) {
45 | val expectedList = expected.asList()
46 | if (values == expectedList) {
47 | return
48 | }
49 | try {
50 | withTimeout(timeout) {
51 | for (value in channel) {
52 | if (values == expectedList) {
53 | return@withTimeout
54 | }
55 | }
56 | }
57 | } catch (ex: TimeoutCancellationException) {
58 | Truth.assertThat(values).isEqualTo(expectedList)
59 | }
60 | }
61 | }
62 |
63 | /**
64 | * Extension function to capture all values that are emitted to a LiveData during the execution of
65 | * `captureBlock`.
66 | *
67 | * @param captureBlock a lambda that will
68 | */
69 | inline fun LiveData.captureValues(block: LiveDataValueCapture.() -> Unit) {
70 | val capture = LiveDataValueCapture()
71 | val observer = Observer {
72 | capture.addValue(it)
73 | }
74 | observeForever(observer)
75 | capture.block()
76 | removeObserver(observer)
77 | }
78 |
79 | /**
80 | * Get the current value from a LiveData without needing to register an observer.
81 | */
82 | fun LiveData.getValueForTest(): T? {
83 | var value: T? = null
84 | var observer = Observer {
85 | value = it
86 | }
87 | observeForever(observer)
88 | removeObserver(observer)
89 | return value
90 | }
91 |
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext.kotlin_version = '1.3.31'
5 | repositories {
6 | google()
7 | jcenter()
8 |
9 | }
10 | dependencies {
11 | classpath 'com.android.tools.build:gradle:3.4.0'
12 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
13 | // NOTE: Do not place your application dependencies here; they belong
14 | // in the individual module build.gradle files
15 | }
16 | }
17 |
18 | allprojects {
19 | repositories {
20 | google()
21 | jcenter()
22 |
23 | }
24 | }
25 |
26 | task clean(type: Delete) {
27 | delete rootProject.buildDir
28 | }
29 |
--------------------------------------------------------------------------------
/data/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/data/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 | apply plugin: 'kotlin-android'
3 | apply plugin: 'kotlin-android-extensions'
4 | apply plugin: 'kotlin-kapt'
5 | apply plugin: 'realm-android'
6 |
7 | buildscript {
8 | repositories {
9 | jcenter()
10 | }
11 | dependencies {
12 | classpath "io.realm:realm-gradle-plugin:5.2.0"
13 | }
14 | }
15 |
16 | realm {
17 | syncEnabled = true;
18 | }
19 |
20 | android {
21 | compileSdkVersion 28
22 | defaultConfig {
23 | minSdkVersion 17
24 | targetSdkVersion 28
25 | versionCode 1
26 | versionName "1.0"
27 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
28 | }
29 | buildTypes {
30 | release {
31 | minifyEnabled false
32 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
33 | }
34 | }
35 |
36 | }
37 |
38 | dependencies {
39 | implementation fileTree(dir: 'libs', include: ['*.jar'])
40 | implementation project(path: ':domain')
41 | implementation 'androidx.appcompat:appcompat:1.0.2'
42 | testImplementation 'junit:junit:4.12'
43 | androidTestImplementation 'androidx.test:runner:1.1.1'
44 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
45 |
46 | // Retrofit & OkHttp
47 | implementation 'com.squareup.retrofit2:retrofit:2.4.0'
48 | implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
49 | implementation "com.squareup.okhttp3:logging-interceptor:3.9.0"
50 | }
51 |
--------------------------------------------------------------------------------
/data/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/data/src/androidTest/kotlin/com/globant/data/ExampleInstrumentedTest.java:
--------------------------------------------------------------------------------
1 | package com.globant.data;
2 |
3 | import android.content.Context;
4 | import androidx.test.InstrumentationRegistry;
5 | import androidx.test.runner.AndroidJUnit4;
6 |
7 | import org.junit.Test;
8 | import org.junit.runner.RunWith;
9 |
10 | import static org.junit.Assert.*;
11 |
12 | /**
13 | * Instrumented test, which will execute on an Android device.
14 | *
15 | * @see Testing documentation
16 | */
17 | @RunWith(AndroidJUnit4.class)
18 | public class ExampleInstrumentedTest {
19 | @Test
20 | public void useAppContext() {
21 | // Context of the app under test.
22 | Context appContext = InstrumentationRegistry.getTargetContext();
23 |
24 | assertEquals("com.globant.data.test", appContext.getPackageName());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/data/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/MarvelRequestGenerator.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data
2 |
3 | import android.util.Log
4 | import okhttp3.OkHttpClient
5 | import okhttp3.logging.HttpLoggingInterceptor
6 | import retrofit2.Retrofit
7 | import retrofit2.converter.gson.GsonConverterFactory
8 |
9 | private const val PRIVATE_API_KEY_ARG = "hash"
10 | private const val PRIVATE_API_KEY_ARG_VALUE = "b9baa830257d91dacc32db89d34d1f09"
11 | private const val PUBLIC_API_KEY_ARG = "apikey"
12 | private const val PUBLIC_API_KEY_ARG_VALUE = "3e207d05ea54389185beb3caa92ffc66"
13 | private const val MARVEL_BASE_URL = "http://gateway.marvel.com/public/"
14 | private const val TS = "ts"
15 | private const val TS_VALUE = "1"
16 | private const val MAX_TRYOUTS = 3
17 | private const val INIT_TRYOUT = 1
18 |
19 | class MarvelRequestGenerator {
20 |
21 | private val httpClient = OkHttpClient.Builder()
22 | .addInterceptor(
23 | HttpLoggingInterceptor().apply {
24 | this.level = HttpLoggingInterceptor.Level.BODY
25 | }
26 | )
27 | .addInterceptor { chain ->
28 | val defaultRequest = chain.request()
29 |
30 | val defaultHttpUrl = defaultRequest.url()
31 |
32 | val httpUrl = defaultHttpUrl.newBuilder()
33 | .addQueryParameter(PUBLIC_API_KEY_ARG, PRIVATE_API_KEY_ARG_VALUE)
34 | .addQueryParameter(PRIVATE_API_KEY_ARG, PUBLIC_API_KEY_ARG_VALUE)
35 | .addQueryParameter(TS, TS_VALUE)
36 | .build()
37 |
38 | val requestBuilder = defaultRequest.newBuilder()
39 | .url(httpUrl)
40 |
41 | chain.proceed(requestBuilder.build())
42 | }
43 | .addInterceptor { chain ->
44 | val request = chain.request()
45 | var response = chain.proceed(request)
46 | var tryOuts = INIT_TRYOUT
47 |
48 | while (!response.isSuccessful && tryOuts < MAX_TRYOUTS) {
49 | Log.d(
50 | this@MarvelRequestGenerator.javaClass.simpleName, "intercept: timeout/connection failure, " +
51 | "performing automatic retry ${(tryOuts + 1)}"
52 | )
53 | tryOuts++
54 | response = chain.proceed(request)
55 | }
56 |
57 | response
58 | }
59 |
60 | private val builder = Retrofit.Builder()
61 | .baseUrl(MARVEL_BASE_URL)
62 | .addConverterFactory(GsonConverterFactory.create())
63 |
64 | fun createService(serviceClass: Class): S {
65 | val retrofit = builder.client(httpClient.build()).build()
66 | return retrofit.create(serviceClass)
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data
2 |
3 | const val EMPTY_STRING = ""
4 | const val DEFAULT_ID = 0
5 | const val ZERO = 0
6 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/database/CharacterDatabase.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.database
2 |
3 | import com.globant.data.database.entity.MarvelCharacterRealm
4 | import com.globant.data.mapper.CharacterMapperLocal
5 | import com.globant.domain.entities.MarvelCharacter
6 | import com.globant.domain.utils.Result
7 | import io.realm.Realm
8 |
9 | class CharacterDatabase {
10 |
11 | fun getCharacterById(id: Int): Result {
12 | val mapper = CharacterMapperLocal()
13 | Realm.getDefaultInstance().use {
14 | val character = it.where(MarvelCharacterRealm::class.java).equalTo("id", id).findFirst()
15 | character?.let { return Result.Success(mapper.transform(character)) }
16 | return Result.Failure(Exception("Character not found"))
17 | }
18 | }
19 |
20 | fun insertOrUpdateCharacter(character: MarvelCharacter) {
21 | val mapperLocal = CharacterMapperLocal()
22 | Realm.getDefaultInstance().use {
23 | it.executeTransaction { realm ->
24 | realm.insertOrUpdate(mapperLocal.transformToRepository(character))
25 | }
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/database/entity/MarvelCharacterRealm.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.database.entity
2 |
3 | import com.globant.data.EMPTY_STRING
4 | import com.globant.data.DEFAULT_ID
5 | import io.realm.RealmObject
6 | import io.realm.annotations.PrimaryKey
7 |
8 | open class MarvelCharacterRealm(
9 | @PrimaryKey
10 | var id: Int = DEFAULT_ID,
11 | var name: String = EMPTY_STRING,
12 | var description: String = EMPTY_STRING
13 | ) : RealmObject()
14 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/database/response/DataBaseResponse.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.database.response
2 |
3 | import com.globant.data.service.response.CharacterResponse
4 | import com.google.gson.annotations.SerializedName
5 |
6 | class DataBaseResponse(
7 | @SerializedName("results") val characters: List,
8 | val offset: Int,
9 | val limit: Int,
10 | val total: Int
11 | )
12 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/mapper/BaseMapperRepository.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.mapper
2 | /**
3 | * Interface for model mappers. It provides helper methods that facilitate
4 | * retrieving of models from outer data source layers
5 | *
6 | * @param the cached model input type
7 | * @param the remote model input type
8 | * @param the model return type
9 | */
10 | interface BaseMapperRepository {
11 |
12 | fun transform(type: E): D
13 |
14 | fun transformToRepository(type: D): E
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/mapper/CharacterMapperLocal.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.mapper
2 |
3 | import com.globant.data.database.entity.MarvelCharacterRealm
4 | import com.globant.domain.entities.MarvelCharacter
5 |
6 | class CharacterMapperLocal : BaseMapperRepository {
7 |
8 | override fun transform(type: MarvelCharacterRealm): MarvelCharacter = MarvelCharacter(
9 | type.id,
10 | type.name,
11 | type.description
12 | )
13 |
14 | override fun transformToRepository(type: MarvelCharacter): MarvelCharacterRealm = MarvelCharacterRealm(
15 | type.id,
16 | type.name,
17 | type.description
18 | )
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/mapper/CharacterMapperService.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.mapper
2 |
3 | import com.globant.data.service.response.CharacterResponse
4 | import com.globant.domain.entities.MarvelCharacter
5 |
6 | open class CharacterMapperService : BaseMapperRepository {
7 |
8 | override fun transform(type: CharacterResponse): MarvelCharacter =
9 | MarvelCharacter(
10 | type.id,
11 | type.name,
12 | type.description
13 | )
14 |
15 | override fun transformToRepository(type: MarvelCharacter): CharacterResponse =
16 | CharacterResponse(
17 | type.id,
18 | type.name,
19 | type.description
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/repositories/MarvelCharacterRepositoryImpl.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.repositories
2 |
3 | import com.globant.data.database.CharacterDatabase
4 | import com.globant.data.service.CharacterService
5 | import com.globant.domain.entities.MarvelCharacter
6 | import com.globant.domain.repositories.MarvelCharacterRepository
7 | import com.globant.domain.utils.Result
8 |
9 | class MarvelCharacterRepositoryImpl(
10 | private val characterService: CharacterService,
11 | private val characterDatabase: CharacterDatabase
12 | ) : MarvelCharacterRepository {
13 |
14 | override fun getCharacterById(id: Int, getFromRemote: Boolean): Result =
15 | if (getFromRemote) {
16 | val marvelCharacterResult = characterService.getCharacterById(id)
17 | if (marvelCharacterResult is Result.Success) {
18 | insertOrUpdateCharacter(marvelCharacterResult.data)
19 | }
20 | marvelCharacterResult
21 | } else {
22 | characterDatabase.getCharacterById(id)
23 | }
24 |
25 | private fun insertOrUpdateCharacter(character: MarvelCharacter) {
26 | characterDatabase.insertOrUpdateCharacter(character)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/service/CharacterService.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.service
2 |
3 | import com.globant.data.MarvelRequestGenerator
4 | import com.globant.data.ZERO
5 | import com.globant.data.mapper.CharacterMapperService
6 | import com.globant.data.service.api.MarvelApi
7 | import com.globant.domain.entities.MarvelCharacter
8 | import com.globant.domain.utils.Result
9 |
10 | class CharacterService {
11 |
12 | private val api: MarvelRequestGenerator = MarvelRequestGenerator()
13 | private val mapper: CharacterMapperService = CharacterMapperService()
14 |
15 | fun getCharacterById(id: Int): Result {
16 | val callResponse = api.createService(MarvelApi::class.java).getCharacterById(id)
17 | val response = callResponse.execute()
18 | if (response != null) {
19 | if (response.isSuccessful) {
20 | response.body()?.data?.characters?.get(ZERO)?.let { mapper.transform(it) }?.let { return Result.Success(it) }
21 | }
22 | return Result.Failure(Exception(response.message()))
23 | }
24 | return Result.Failure(Exception("Bad request/response"))
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/service/api/MarvelApi.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.service.api
2 |
3 | import com.globant.data.service.response.CharacterResponse
4 | import com.globant.data.database.response.DataBaseResponse
5 | import com.globant.data.service.response.MarvelBaseResponse
6 | import retrofit2.Call
7 | import retrofit2.http.GET
8 | import retrofit2.http.Path
9 |
10 | interface MarvelApi {
11 | @GET("/v1/public/characters/{characterId}")
12 | fun getCharacterById(@Path("characterId")id: Int): Call>>>
13 | }
14 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/service/response/CharacterResponse.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.service.response
2 |
3 | class CharacterResponse (
4 | val id: Int,
5 | val name: String,
6 | val description: String
7 | )
8 |
--------------------------------------------------------------------------------
/data/src/main/java/com/globant/data/service/response/MarvelBaseResponse.kt:
--------------------------------------------------------------------------------
1 | package com.globant.data.service.response
2 |
3 | class MarvelBaseResponse(
4 |
5 | var code: Int,
6 | var status: String,
7 | var data: T?
8 | )
9 |
--------------------------------------------------------------------------------
/data/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | data
3 |
4 |
--------------------------------------------------------------------------------
/data/src/test/java/com/globant/data/ExampleUnitTest.java:
--------------------------------------------------------------------------------
1 | package com.globant.data;
2 |
3 | import org.junit.Test;
4 |
5 | import static org.junit.Assert.*;
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * @see Testing documentation
11 | */
12 | public class ExampleUnitTest {
13 | @Test
14 | public void addition_isCorrect() {
15 | assertEquals(4, 2 + 2);
16 | }
17 | }
--------------------------------------------------------------------------------
/di/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/di/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 | apply plugin: 'kotlin-android'
3 |
4 | android {
5 | compileSdkVersion 28
6 | defaultConfig {
7 | minSdkVersion 17
8 | targetSdkVersion 28
9 | versionCode 1
10 | versionName "1.0"
11 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
12 |
13 | }
14 |
15 | buildTypes {
16 | release {
17 | minifyEnabled false
18 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
19 | }
20 | }
21 |
22 | }
23 |
24 | dependencies {
25 | implementation fileTree(dir: 'libs', include: ['*.jar'])
26 | implementation project(path: ':domain')
27 | implementation project(path: ':data')
28 |
29 | implementation "org.koin:koin-androidx-viewmodel:2.0.0-GA"
30 | implementation 'androidx.appcompat:appcompat:1.0.2'
31 | testImplementation 'junit:junit:4.12'
32 | androidTestImplementation 'androidx.test:runner:1.1.1'
33 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
34 | }
35 |
--------------------------------------------------------------------------------
/di/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
22 |
--------------------------------------------------------------------------------
/di/src/androidTest/java/com/globant/di/ExampleInstrumentedTest.java:
--------------------------------------------------------------------------------
1 | package com.globant.di;
2 |
3 | import android.content.Context;
4 | import android.support.test.InstrumentationRegistry;
5 | import android.support.test.runner.AndroidJUnit4;
6 |
7 | import org.junit.Test;
8 | import org.junit.runner.RunWith;
9 |
10 | import static org.junit.Assert.*;
11 |
12 | /**
13 | * Instrumented test, which will execute on an Android device.
14 | *
15 | * @see Testing documentation
16 | */
17 | @RunWith(AndroidJUnit4.class)
18 | public class ExampleInstrumentedTest {
19 | @Test
20 | public void useAppContext() {
21 | // Context of the app under test.
22 | Context appContext = InstrumentationRegistry.getTargetContext();
23 |
24 | assertEquals("com.globant.di.test", appContext.getPackageName());
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/di/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
3 |
--------------------------------------------------------------------------------
/di/src/main/java/com/globant/KoinModules.kt:
--------------------------------------------------------------------------------
1 | package com.globant
2 |
3 | import com.globant.data.database.CharacterDatabase
4 | import com.globant.data.repositories.MarvelCharacterRepositoryImpl
5 | import com.globant.data.service.CharacterService
6 | import com.globant.domain.repositories.MarvelCharacterRepository
7 | import org.koin.dsl.module
8 |
9 | val repositoriesModule = module {
10 | single { CharacterService() }
11 | single { CharacterDatabase() }
12 | single { MarvelCharacterRepositoryImpl(get(), get()) }
13 | }
--------------------------------------------------------------------------------
/di/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | di
3 |
4 |
--------------------------------------------------------------------------------
/di/src/test/java/com/globant/di/ExampleUnitTest.java:
--------------------------------------------------------------------------------
1 | package com.globant.di;
2 |
3 | import org.junit.Test;
4 |
5 | import static org.junit.Assert.*;
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * @see Testing documentation
11 | */
12 | public class ExampleUnitTest {
13 | @Test
14 | public void addition_isCorrect() {
15 | assertEquals(4, 2 + 2);
16 | }
17 | }
--------------------------------------------------------------------------------
/domain/.gitignore:
--------------------------------------------------------------------------------
1 | /build
2 |
--------------------------------------------------------------------------------
/domain/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'kotlin'
2 |
3 | dependencies {
4 | implementation "org.koin:koin-androidx-viewmodel:2.0.0-GA"
5 | }
6 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/globant/domain/entities/MarvelCharacter.kt:
--------------------------------------------------------------------------------
1 | package com.globant.domain.entities
2 |
3 | val NOT_FOUND = "NOT FOUND"
4 | val DEFAULT_ID = 0
5 |
6 | class MarvelCharacter(
7 | val id: Int = DEFAULT_ID,
8 | val name: String = NOT_FOUND,
9 | val description: String = NOT_FOUND
10 | )
11 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/globant/domain/repositories/MarvelCharacterRepository.kt:
--------------------------------------------------------------------------------
1 | package com.globant.domain.repositories
2 |
3 | import com.globant.domain.entities.MarvelCharacter
4 | import com.globant.domain.utils.Result
5 |
6 | interface MarvelCharacterRepository {
7 | fun getCharacterById(id: Int, getFromRemote: Boolean): Result
8 | }
9 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/globant/domain/usecases/GetCharacterByIdUseCase.kt:
--------------------------------------------------------------------------------
1 | package com.globant.domain.usecases
2 |
3 | import com.globant.domain.repositories.MarvelCharacterRepository
4 | import org.koin.core.KoinComponent
5 | import org.koin.core.inject
6 |
7 | class GetCharacterByIdUseCase: KoinComponent {
8 | private val marvelCharacterRepository: MarvelCharacterRepository by inject()
9 | operator fun invoke(id: Int, getFromRemote: Boolean) = marvelCharacterRepository.getCharacterById(id, getFromRemote)
10 | }
11 |
--------------------------------------------------------------------------------
/domain/src/main/java/com/globant/domain/utils/Result.kt:
--------------------------------------------------------------------------------
1 | package com.globant.domain.utils
2 |
3 | sealed class Result {
4 | class Success(val data: T) : Result()
5 | class Failure(val exception: Exception) : Result()
6 | }
7 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/priettt/kotlin-mvvm-clean-sample/f8b7edb667257a93555a414625120a9419ff0f5e/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Tue May 07 15:46:14 ART 2019
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
7 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | include ':app', ':domain', ':data', ':di'
2 |
--------------------------------------------------------------------------------