WIP: feature/abstractions (#45)

* Abstraction layer backup

* Removed DataEntity, was unnecessary for now

* Separated network, persistence, entities and interaction, closes #29

* Renamed binding

* Removed build files, example tests

Removed build files, example tests

* Fixed build files were not being ignored all around app

* Updated CI ymls

* Small changes

* Fixed legacy repository package names

* Fixed CQ findings

* Updated Fastlane

* Packaging changes and version upgrades

* Removed core from interactors

* Version bumps

* Added new module graph
This commit is contained in:
Melih Aksoy
2019-10-30 17:27:53 +01:00
committed by GitHub
parent 83e39400a9
commit 88022629e1
103 changed files with 1098 additions and 921 deletions

View File

@@ -76,12 +76,60 @@ jobs:
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/ - store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: features/detail/build/test-results path: features/detail/build/test-results
## Repository ## Abstractions
- run: - run:
name: Test Repository name: Test Abstractions
command: | command: |
fastlane test_repository fastlane test_abstractions
./gradlew repository:jacocoTestReport ./gradlew abstractions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Definitions
- run:
name: Test Definitions
command: |
fastlane test_definitions
./gradlew data:definitions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Interactors
- run:
name: Test Interactors
command: |
fastlane test_interactors
./gradlew data:interactors:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Network
- run:
name: Test Network
command: |
fastlane test_network
./gradlew data:network:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests
- store_test_results: # for display in Test Summary: https://circleci.com/docs/2.0/collect-test-data/
path: repository/build/test-results
## Persistence
- run:
name: Test Persistence
command: |
fastlane test_persistence
./gradlew data:persistence:jacocoTestReport
bash <(curl -s https://codecov.io/bash) bash <(curl -s https://codecov.io/bash)
- store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/ - store_artifacts: # for display in Artifacts: https://circleci.com/docs/2.0/artifacts/
path: repository/build/reports/tests path: repository/build/reports/tests

View File

@@ -55,8 +55,32 @@ jobs:
./gradlew features:detail:jacocoTestReport ./gradlew features:detail:jacocoTestReport
bash <(curl -s https://codecov.io/bash) bash <(curl -s https://codecov.io/bash)
- name: Test Repository - name: Test Abstractions
run: | run: |
fastlane test_repository fastlane test_abstractions
./gradlew repository:jacocoTestReport ./gradlew abstractions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Definitions
run: |
fastlane test_definitions
./gradlew data:definitions:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Interactors
run: |
fastlane test_interactors
./gradlew data:interactors:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Network
run: |
fastlane test_network
./gradlew data:network:jacocoTestReport
bash <(curl -s https://codecov.io/bash)
- name: Test Persistence
run: |
fastlane test_persistence
./gradlew data:persistence:jacocoTestReport
bash <(curl -s https://codecov.io/bash) bash <(curl -s https://codecov.io/bash)

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
/.idea /.idea
.DS_Store .DS_Store
/build /build
**/build
/fastlane/README.md /fastlane/README.md
/fastlane/report.xml /fastlane/report.xml
/captures /captures

View File

@@ -5,7 +5,7 @@ GEM
addressable (2.7.0) addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0) public_suffix (>= 2.0.2, < 5.0)
atomos (0.1.3) atomos (0.1.3)
babosa (1.0.2) babosa (1.0.3)
claide (1.0.3) claide (1.0.3)
colored (1.2) colored (1.2)
colored2 (3.1.2) colored2 (3.1.2)
@@ -18,7 +18,7 @@ GEM
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
dotenv (2.7.5) dotenv (2.7.5)
emoji_regex (1.0.1) emoji_regex (1.0.1)
excon (0.66.0) excon (0.67.0)
faraday (0.15.4) faraday (0.15.4)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
faraday-cookie_jar (0.0.6) faraday-cookie_jar (0.0.6)
@@ -27,7 +27,7 @@ GEM
faraday_middleware (0.13.1) faraday_middleware (0.13.1)
faraday (>= 0.7.4, < 1.0) faraday (>= 0.7.4, < 1.0)
fastimage (2.1.7) fastimage (2.1.7)
fastlane (2.131.0) fastlane (2.133.0)
CFPropertyList (>= 2.3, < 4.0.0) CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.3, < 3.0.0) addressable (>= 2.3, < 3.0.0)
babosa (>= 1.0.2, < 2.0.0) babosa (>= 1.0.2, < 2.0.0)
@@ -37,9 +37,9 @@ GEM
dotenv (>= 2.1.1, < 3.0.0) dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 2.0) emoji_regex (>= 0.1, < 2.0)
excon (>= 0.45.0, < 1.0.0) excon (>= 0.45.0, < 1.0.0)
faraday (~> 0.9) faraday (< 0.16.0)
faraday-cookie_jar (~> 0.0.6) faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 0.9) faraday_middleware (< 0.16.0)
fastimage (>= 2.1.0, < 3.0.0) fastimage (>= 2.1.0, < 3.0.0)
gh_inspector (>= 1.1.2, < 2.0.0) gh_inspector (>= 1.1.2, < 2.0.0)
google-api-client (>= 0.21.2, < 0.24.0) google-api-client (>= 0.21.2, < 0.24.0)
@@ -52,7 +52,7 @@ GEM
multipart-post (~> 2.0.0) multipart-post (~> 2.0.0)
plist (>= 3.1.0, < 4.0.0) plist (>= 3.1.0, < 4.0.0)
public_suffix (~> 2.0.0) public_suffix (~> 2.0.0)
rubyzip (>= 1.2.2, < 2.0.0) rubyzip (>= 1.3.0, < 2.0.0)
security (= 0.1.3) security (= 0.1.3)
simctl (~> 1.6.3) simctl (~> 1.6.3)
slack-notifier (>= 2.0.0, < 3.0.0) slack-notifier (>= 2.0.0, < 3.0.0)
@@ -98,7 +98,7 @@ GEM
memoist (0.16.0) memoist (0.16.0)
mime-types (3.3) mime-types (3.3)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2019.0904) mime-types-data (3.2019.1009)
mini_magick (4.9.5) mini_magick (4.9.5)
multi_json (1.13.1) multi_json (1.13.1)
multi_xml (0.6.0) multi_xml (0.6.0)
@@ -114,7 +114,7 @@ GEM
uber (< 0.2.0) uber (< 0.2.0)
retriable (3.1.2) retriable (3.1.2)
rouge (2.0.7) rouge (2.0.7)
rubyzip (1.2.4) rubyzip (1.3.0)
security (0.1.3) security (0.1.3)
signet (0.11.0) signet (0.11.0)
addressable (~> 2.3) addressable (~> 2.3)

17
abstractions/build.gradle Normal file
View File

@@ -0,0 +1,17 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: "de.mannodermaus.android-junit5"
apply from: "$rootProject.projectDir/scripts/module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libraries.kotlin
testImplementation testLibraries.jUnitApi
testImplementation testLibraries.mockk
testImplementation testLibraries.kluent
testRuntimeOnly testLibraries.jUnitEngine
}

21
abstractions/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.abstractions" />

View File

@@ -0,0 +1,3 @@
package com.melih.abstractions.data
interface ViewEntity

View File

@@ -0,0 +1,6 @@
package com.melih.abstractions.deliverable
abstract class Reason : Throwable() {
abstract val messageRes: Int
}

View File

@@ -1,12 +1,9 @@
package com.melih.repository.interactors.base package com.melih.abstractions.deliverable
import kotlinx.coroutines.ExperimentalCoroutinesApi
/** /**
* Result class that wraps any [Success], [Failure] or [State] that can be generated by any derivation of [BaseInteractor] * Result class that wraps any [Success], [Failure] or [State]
*/ */
@UseExperimental(ExperimentalCoroutinesApi::class)
sealed class Result<out T> sealed class Result<out T>
//region Subclasses //region Subclasses
@@ -22,7 +19,11 @@ sealed class State : Result<Nothing>() {
//region Extensions //region Extensions
inline fun <T> Result<T>.handle(stateBlock: (State) -> Unit, failureBlock: (Reason) -> Unit, successBlock: (T) -> Unit) { inline fun <T> Result<T>.handle(
stateBlock: (State) -> Unit,
failureBlock: (Reason) -> Unit,
successBlock: (T) -> Unit
) {
when (this) { when (this) {
is Success -> successBlock(successData) is Success -> successBlock(successData)
is Failure -> failureBlock(errorData) is Failure -> failureBlock(errorData)

View File

@@ -0,0 +1,8 @@
package com.melih.abstractions.mapper
import com.melih.abstractions.data.ViewEntity
abstract class Mapper<in T, out R : ViewEntity> {
abstract fun convert(t: T): R
}

View File

@@ -1,6 +1,10 @@
package com.melih.repository.interactors.base package com.melih.abstractions
import com.melih.repository.R import com.melih.abstractions.deliverable.Failure
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.Success
import com.melih.abstractions.deliverable.handle
import io.mockk.called import io.mockk.called
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
@@ -8,12 +12,17 @@ import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class ResultTest { class ResultTest {
private val number = 10 private val number = 10
private val success = Success(number) private val success = Success(number)
private val failure = Failure(GenericError()) private val failure = Failure(object : Reason() {
override val messageRes: Int
get() = 10
})
private val state = State.Loading() private val state = State.Loading()
private val emptyStateBlock = spyk({ _: State -> }) private val emptyStateBlock = spyk({ _: State -> })
@@ -37,8 +46,7 @@ class ResultTest {
@Test @Test
fun `Failure should only invoke failureBlock with correct error`() { fun `Failure should only invoke failureBlock with correct error`() {
val actualFailureBlock = spyk({ reason: Reason -> val actualFailureBlock = spyk({ reason: Reason ->
reason shouldBeInstanceOf GenericError::class reason.messageRes shouldEqualTo 10
(reason as GenericError).messageRes shouldEqualTo R.string.reason_generic
Unit Unit
}) })

View File

@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/default_android_config.gradle" apply from: "$rootProject.projectDir/scripts/default_android_config.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
apply from: "$rootProject.projectDir/scripts/sources.gradle" apply from: "$rootProject.projectDir/scripts/sources.gradle"
android { android {
@@ -31,10 +32,14 @@ dependencies {
androidTestImplementation testLibraries.espresso androidTestImplementation testLibraries.espresso
// These libraries required by dagger to create dependency graph, but not by app // These libraries required by dagger to create dependency graph and application compilation, but not used by app
compileOnly project(':repository')
compileOnly project(':abstractions')
compileOnly project(':data:interactors')
compileOnly project(':data:network')
compileOnly project(':data:definitions')
compileOnly libraries.retrofit compileOnly libraries.retrofit
compileOnly libraries.room
compileOnly libraries.paging compileOnly libraries.paging
compileOnly libraries.swipeRefreshLayout compileOnly libraries.swipeRefreshLayout

View File

@@ -21,8 +21,5 @@
@com.squareup.moshi.ToJson <methods>; @com.squareup.moshi.ToJson <methods>;
} }
-keepnames @kotlin.Metadata class com.myapp.packagename.model.**
-keep class com.myapp.packagnename.model.** { *; }
# Keeping entities intact # Keeping entities intact
-keep class com.melih.repository.entities.** { *; } -keep class com.melih.definitions.entities.** { *; }

View File

@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
ext.kotlin_version = '1.3.50' ext.kotlin_version = '1.3.50'
ext.nav_version = '2.2.0-alpha01' ext.nav_version = '2.2.0-beta01'
repositories { repositories {
google() google()
@@ -9,7 +9,7 @@ buildscript {
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:3.5.0' classpath 'com.android.tools.build:gradle:3.5.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18" classpath "org.jetbrains.dokka:dokka-android-gradle-plugin:0.9.18"
classpath "de.mannodermaus.gradle.plugins:android-junit5:1.5.1.0" classpath "de.mannodermaus.gradle.plugins:android-junit5:1.5.1.0"
@@ -20,9 +20,9 @@ buildscript {
} }
plugins { plugins {
id "io.gitlab.arturbosch.detekt" version "1.0.0" id 'io.gitlab.arturbosch.detekt' version '1.1.1'
id "org.jetbrains.dokka" version "0.9.18" id 'org.jetbrains.dokka' version '0.9.18'
id "jacoco" id 'jacoco'
} }
allprojects { allprojects {
@@ -97,7 +97,9 @@ task projectDependencyGraph {
rootProjects.remove(dependency) rootProjects.remove(dependency)
def graphKey = new Tuple2<Project, Project>(project, dependency) def graphKey = new Tuple2<Project, Project>(project, dependency)
def traits = dependencies.computeIfAbsent(graphKey) { new ArrayList<String>() } def traits = dependencies.computeIfAbsent(graphKey) {
new ArrayList<String>()
}
if (config.name.toLowerCase().endsWith('implementation')) { if (config.name.toLowerCase().endsWith('implementation')) {
traits.add('style=dotted') traits.add('style=dotted')

View File

@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/default_android_config.gradle" apply from: "$rootProject.projectDir/scripts/default_android_config.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
apply from: "$rootProject.projectDir/scripts/sources.gradle" apply from: "$rootProject.projectDir/scripts/sources.gradle"
android { android {
@@ -14,8 +15,7 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':repository') implementation libraries.coroutines
implementation libraries.fragment implementation libraries.fragment
implementation libraries.paging implementation libraries.paging
implementation libraries.lifecycle implementation libraries.lifecycle

View File

@@ -9,8 +9,8 @@ import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding import androidx.databinding.ViewDataBinding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.melih.abstractions.deliverable.Reason
import com.melih.core.R import com.melih.core.R
import com.melih.repository.interactors.base.Reason
/** /**
* Parent of all fragments. * Parent of all fragments.

View File

@@ -4,12 +4,13 @@ import androidx.annotation.CallSuper
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.paging.PageKeyedDataSource import androidx.paging.PageKeyedDataSource
import com.melih.repository.interactors.base.Reason import com.melih.abstractions.data.ViewEntity
import com.melih.repository.interactors.base.Result import com.melih.abstractions.deliverable.Reason
import com.melih.repository.interactors.base.State import com.melih.abstractions.deliverable.Result
import com.melih.repository.interactors.base.onFailure import com.melih.abstractions.deliverable.State
import com.melih.repository.interactors.base.onState import com.melih.abstractions.deliverable.onFailure
import com.melih.repository.interactors.base.onSuccess import com.melih.abstractions.deliverable.onState
import com.melih.abstractions.deliverable.onSuccess
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -35,11 +36,11 @@ const val INITIAL_PAGE = 0
*/ */
@UseExperimental(ExperimentalCoroutinesApi::class) @UseExperimental(ExperimentalCoroutinesApi::class)
abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() { abstract class BasePagingDataSource<R : ViewEntity> : PageKeyedDataSource<Int, R>() {
//region Abstractions //region Abstractions
abstract fun loadDataForPage(page: Int): Flow<Result<List<T>>> // Load next page(s) abstract fun loadDataForPage(page: Int): Flow<Result<List<R>>> // Load next page(s)
//endregion //endregion
//region Properties //region Properties
@@ -63,7 +64,10 @@ abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
//region Functions //region Functions
override fun loadInitial(params: LoadInitialParams<Int>, callback: LoadInitialCallback<Int, T>) { override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Int, R>
) {
// Looping through channel as we'll receive any state, error or data here // Looping through channel as we'll receive any state, error or data here
loadDataForPage(INITIAL_PAGE) loadDataForPage(INITIAL_PAGE)
.onEach { result -> .onEach { result ->
@@ -81,7 +85,7 @@ abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, T>) { override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, R>) {
// Key for which page to load is in params // Key for which page to load is in params
val page = params.key val page = params.key
@@ -104,7 +108,7 @@ abstract class BasePagingDataSource<T> : PageKeyedDataSource<Int, T>() {
/** /**
* This loads previous pages, we don't have a use for it yet, so it's a no-op override * This loads previous pages, we don't have a use for it yet, so it's a no-op override
*/ */
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, T>) { override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, R>) {
// no-op // no-op
} }

View File

@@ -3,6 +3,7 @@ package com.melih.core.base.paging
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource import androidx.paging.DataSource
import com.melih.abstractions.data.ViewEntity
/** /**
* Base [factory][DataSource.Factory] class for any [dataSource][DataSource]s in project. * Base [factory][DataSource.Factory] class for any [dataSource][DataSource]s in project.
@@ -14,7 +15,7 @@ import androidx.paging.DataSource
* *
* Purpose of this transmission is to encapuslate [basePagingDataSource][BasePagingDataSource]. * Purpose of this transmission is to encapuslate [basePagingDataSource][BasePagingDataSource].
*/ */
abstract class BasePagingFactory<T> : DataSource.Factory<Int, T>() { abstract class BasePagingFactory<T : ViewEntity> : DataSource.Factory<Int, T>() {
//region Abstractions //region Abstractions

View File

@@ -5,9 +5,10 @@ import androidx.lifecycle.Transformations.switchMap
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.paging.PagedList import androidx.paging.PagedList
import androidx.paging.toLiveData import androidx.paging.toLiveData
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.State
import com.melih.core.base.paging.BasePagingFactory import com.melih.core.base.paging.BasePagingFactory
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.State
/** /**
* Base [ViewModel] for view models that will use [PagedList]. * Base [ViewModel] for view models that will use [PagedList].
@@ -18,7 +19,7 @@ import com.melih.repository.interactors.base.State
* *
* If paging won't be used, use [BaseViewModel] instead. * If paging won't be used, use [BaseViewModel] instead.
*/ */
abstract class BasePagingViewModel<T> : ViewModel() { abstract class BasePagingViewModel<T : ViewEntity> : ViewModel() {
//region Abstractions //region Abstractions

View File

@@ -4,8 +4,8 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.melih.repository.interactors.base.Reason import com.melih.abstractions.deliverable.Reason
import com.melih.repository.interactors.base.State import com.melih.abstractions.deliverable.State
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**

View File

@@ -1,32 +0,0 @@
package com.melih.core.extensions
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import com.melih.core.utils.ClearFocusQueryTextListener
/**
* Shorthand for [contains] with ignoreCase set [true]
*/
fun CharSequence.containsIgnoreCase(other: CharSequence) = contains(other, true)
/**
* Adds [ClearFocusQueryTextListener] as [SearchView.OnQueryTextListener]
*/
fun SearchView.setOnQueryChangedListener(block: (String?) -> Unit) = setOnQueryTextListener(ClearFocusQueryTextListener(this, block))
/**
* Shortening set menu item expands / collapses
*/
fun MenuItem.onExpandOrCollapse(onExpand: () -> Unit, onCollapse: () -> Unit) {
setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
onCollapse()
return true
}
override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
onExpand()
return true
}
})
}

View File

@@ -3,14 +3,15 @@
package com.melih.core.paging package com.melih.core.paging
import androidx.paging.PageKeyedDataSource import androidx.paging.PageKeyedDataSource
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Failure
import com.melih.abstractions.deliverable.Reason
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.Success
import com.melih.core.BaseTestWithMainThread import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.paging.BasePagingDataSource import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.testObserve import com.melih.core.testObserve
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.GenericError
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.State
import com.melih.repository.interactors.base.Success
import io.mockk.mockk import io.mockk.mockk
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
@@ -28,7 +29,7 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
val failureSource = spyk(TestFailureSource()) val failureSource = spyk(TestFailureSource())
val data = 10 val data = 10
val errorMessage = "Generic Error" val errorMessageResId = 1313
@Nested @Nested
inner class BasePagingSource { inner class BasePagingSource {
@@ -37,10 +38,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
inner class LoadInitial { inner class LoadInitial {
@Test @Test
fun `should update state accordingly`() { fun `should update state accordingly`() {
val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true) val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true) val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking { runBlocking {
@@ -54,10 +54,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
} }
@Test @Test
fun `should update error Error accordingly`() { fun `should update error Error accordingly`() {
val params = PageKeyedDataSource.LoadInitialParams<Int>(10, false) val params = PageKeyedDataSource.LoadInitialParams<Int>(10, false)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true) val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking { runBlocking {
@@ -65,7 +64,7 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
failureSource.loadInitial(params, callback) failureSource.loadInitial(params, callback)
failureSource.reasonData.testObserve { failureSource.reasonData.testObserve {
it shouldBeInstanceOf GenericError::class it shouldBeInstanceOf TestFailureReason::class
} }
} }
} }
@@ -75,10 +74,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
inner class LoadAfter { inner class LoadAfter {
@Test @Test
fun `should update state accordingly`() { fun `should update state accordingly`() {
val params = PageKeyedDataSource.LoadParams(2, 10) val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true) val callback = mockk<PageKeyedDataSource.LoadCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking { runBlocking {
@@ -92,10 +90,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
} }
@Test @Test
fun `should update error Error accordingly`() { fun `should update error Error accordingly`() {
val params = PageKeyedDataSource.LoadParams(2, 10) val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true) val callback = mockk<PageKeyedDataSource.LoadCallback<Int, TestViewEntity>>(relaxed = true)
runBlocking { runBlocking {
@@ -103,17 +100,16 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
failureSource.loadAfter(params, callback) failureSource.loadAfter(params, callback)
failureSource.reasonData.testObserve { failureSource.reasonData.testObserve {
it shouldBeInstanceOf GenericError::class it shouldBeInstanceOf TestFailureReason::class
} }
} }
} }
} }
@Test @Test
fun `should use loadDataForPage in loadInitial and transform emmited value`() { fun `should use loadDataForPage in loadInitial and transform emmited value`() {
val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true) val params = mockk<PageKeyedDataSource.LoadInitialParams<Int>>(relaxed = true)
val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, Int>>(relaxed = true) val callback = mockk<PageKeyedDataSource.LoadInitialCallback<Int, TestViewEntity>>(relaxed = true)
// Fake loading // Fake loading
source.loadInitial(params, callback) source.loadInitial(params, callback)
@@ -126,10 +122,9 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
} }
@Test @Test
fun `should use loadDataForPage in loadAfter and transform emmited value`() { fun `should use loadDataForPage in loadAfter and transform emmited value`() {
val params = PageKeyedDataSource.LoadParams(2, 10) val params = PageKeyedDataSource.LoadParams(2, 10)
val callback = mockk<PageKeyedDataSource.LoadCallback<Int, Int>>(relaxed = true) val callback = mockk<PageKeyedDataSource.LoadCallback<Int, TestViewEntity>>(relaxed = true)
// Fake loading // Fake loading
source.loadAfter(params, callback) source.loadAfter(params, callback)
@@ -142,25 +137,27 @@ class BasePagingDataSourceTest : BaseTestWithMainThread() {
} }
} }
inner class TestSource : BasePagingDataSource<Int>() { inner class TestSource : BasePagingDataSource<TestViewEntity>() {
val result = flow { val result = flow {
emit(State.Loading()) emit(State.Loading())
emit(Success(listOf(data))) emit(Success(listOf(TestViewEntity(data))))
} }
override fun loadDataForPage(page: Int): Flow<Result<List<TestViewEntity>>> = result
override fun loadDataForPage(page: Int): Flow<Result<List<Int>>> = result
} }
inner class TestFailureSource : BasePagingDataSource<Int>() { inner class TestFailureSource : BasePagingDataSource<TestViewEntity>() {
val result = flow { val result = flow {
emit(State.Loading()) emit(State.Loading())
emit(Failure(GenericError())) emit(Failure(TestFailureReason(errorMessageResId)))
} }
override fun loadDataForPage(page: Int): Flow<Result<List<Int>>> = result override fun loadDataForPage(page: Int): Flow<Result<List<TestViewEntity>>> = result
} }
inner class TestViewEntity(data: Int) : ViewEntity
inner class TestFailureReason(override val messageRes: Int) : Reason()
} }

View File

@@ -1,5 +1,6 @@
package com.melih.core.paging package com.melih.core.paging
import com.melih.abstractions.data.ViewEntity
import com.melih.core.BaseTestWithMainThread import com.melih.core.BaseTestWithMainThread
import com.melih.core.base.paging.BasePagingDataSource import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.base.paging.BasePagingFactory import com.melih.core.base.paging.BasePagingFactory
@@ -25,9 +26,10 @@ class BasePagingFactoryTest : BaseTestWithMainThread() {
} }
} }
inner class TestFactory : BasePagingFactory<String>() { inner class TestFactory : BasePagingFactory<TestViewEntity>() {
override fun createSource(): BasePagingDataSource<String> = mockk(relaxed = true)
override fun createSource(): BasePagingDataSource<TestViewEntity> = mockk(relaxed = true)
} }
inner class TestViewEntity : ViewEntity
} }

View File

@@ -0,0 +1,17 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':abstractions')
implementation libraries.room
implementation libraries.moshiKotlin
kapt annotationProcessors.roomCompiler
kapt annotationProcessors.moshi
}

View File

21
data/definitions/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.definitions.entities" />

View File

@@ -1,4 +1,4 @@
package com.melih.repository package com.melih.definitions
internal const val DEFAULT_NAME = "Default name" internal const val DEFAULT_NAME = "Default name"
internal const val EMPTY_STRING = "" internal const val EMPTY_STRING = ""

View File

@@ -0,0 +1,23 @@
package com.melih.definitions
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
/**
* Contract for sources to seperate business logic from build and return type
*/
interface Source {
//region Abstractions
suspend fun <T : ViewEntity> getNextLaunches(
count: Int,
page: Int,
mapper: Mapper<LaunchEntity, T>
): Result<List<T>>
suspend fun <T : ViewEntity> getLaunchById(id: Long, mapper: Mapper<LaunchEntity, T>): Result<T>
//endregion
}

View File

@@ -1,8 +1,8 @@
package com.melih.repository.entities package com.melih.definitions.entities
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.melih.repository.DEFAULT_NAME import com.melih.definitions.DEFAULT_NAME
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass

View File

@@ -1,4 +1,4 @@
package com.melih.repository.entities package com.melih.definitions.entities
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass

View File

@@ -1,7 +1,7 @@
package com.melih.repository.entities package com.melih.definitions.entities
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME import com.melih.definitions.DEFAULT_NAME
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View File

@@ -1,8 +1,8 @@
package com.melih.repository.entities package com.melih.definitions.entities
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME import com.melih.definitions.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING import com.melih.definitions.EMPTY_STRING
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)

View File

@@ -1,8 +1,8 @@
package com.melih.repository.entities package com.melih.definitions.entities
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import com.melih.repository.DEFAULT_NAME import com.melih.definitions.DEFAULT_NAME
import com.melih.repository.EMPTY_STRING import com.melih.definitions.EMPTY_STRING
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass

View File

@@ -0,0 +1,22 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':data:definitions')
implementation project(':data:network')
implementation project(':data:persistence')
implementation libraries.coroutines
implementation libraries.retrofit
testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest
compileOnly libraries.room
}

View File

21
data/interactors/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.interactors" />

View File

@@ -0,0 +1,40 @@
package com.melih.interactors
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.base.BaseInteractor
import com.melih.interactors.base.InteractorParameters
import com.melih.interactors.sources.LaunchesSource
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
/**
* Gets next given number of launches
*/
class GetLaunchDetails<T : ViewEntity> @Inject constructor(
private val mapper: @JvmSuppressWildcards Mapper<LaunchEntity, T>
) : BaseInteractor<T, GetLaunchDetails.Params>() {
//region Properties
@Inject
internal lateinit var launchesSource: LaunchesSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<T>>.run(params: Params) {
emit(launchesSource.getLaunchById(params.id, mapper))
}
//endregion
//region Parameters
data class Params(
val id: Long
) : InteractorParameters
//endregion
}

View File

@@ -0,0 +1,44 @@
package com.melih.interactors
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.base.BaseInteractor
import com.melih.interactors.base.InteractorParameters
import com.melih.interactors.sources.LaunchesSource
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
const val DEFAULT_LAUNCHES_AMOUNT = 15
/**
* Gets next given number of launches
*/
class GetLaunches<T : ViewEntity> @Inject constructor(
private val mapper: @JvmSuppressWildcards Mapper<LaunchEntity, T>
) : BaseInteractor<List<T>, GetLaunches.Params>() {
//region Properties
@Inject
internal lateinit var launchesSource: LaunchesSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<List<T>>>.run(params: Params) {
// Start network fetch - we're not handling state here to ommit them
emit(
launchesSource
.getNextLaunches(params.count, params.page, mapper)
)
}
//endregion
data class Params(
val count: Int = DEFAULT_LAUNCHES_AMOUNT,
val page: Int
) : InteractorParameters
}

View File

@@ -1,5 +1,7 @@
package com.melih.repository.interactors.base package com.melih.interactors.base
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View File

@@ -0,0 +1,17 @@
package com.melih.interactors.error
import androidx.annotation.StringRes
import com.melih.abstractions.deliverable.Reason
import com.melih.interactors.R
sealed class InteractionErrorReason(@StringRes override val messageRes: Int) : Reason()
class GenericError(@StringRes override val messageRes: Int = R.string.reason_generic) : InteractionErrorReason(messageRes)
sealed class NetworkError(override val messageRes: Int) : InteractionErrorReason(messageRes)
class ConnectionError : NetworkError(R.string.reason_network)
class EmptyResultError : NetworkError(R.string.reason_empty_body)
class ResponseError : NetworkError(R.string.reason_response)
class TimeoutError : NetworkError(R.string.reason_timeout)
class PersistenceEmptyError : InteractionErrorReason(R.string.reason_persistance_empty)

View File

@@ -0,0 +1,163 @@
package com.melih.interactors.sources
import android.content.Context
import android.net.NetworkInfo
import com.melih.abstractions.data.ViewEntity
import com.melih.abstractions.deliverable.Failure
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.Success
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.Source
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.interactors.error.ConnectionError
import com.melih.interactors.error.EmptyResultError
import com.melih.interactors.error.NetworkError
import com.melih.interactors.error.PersistenceEmptyError
import com.melih.interactors.error.ResponseError
import com.melih.interactors.error.TimeoutError
import com.melih.network.ApiImpl
import com.melih.persistence.LaunchesDatabase
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
private const val DEFAULT_IMAGE_SIZE = 480
internal class LaunchesSource @Inject constructor(
ctx: Context,
private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo>
) : Source {
//region Properties
private val launchesDatabase = LaunchesDatabase.getInstance(ctx)
private val isNetworkConnected: Boolean
get() {
val networkInfo = networkInfoProvider.get()
return networkInfo != null && networkInfo.isConnected
}
//endregion
//region Functions
override suspend fun <T : ViewEntity> getNextLaunches(
count: Int,
page: Int, mapper: Mapper<LaunchEntity, T>
): Result<List<T>> {
val networkResponse = safeExecute({
apiImpl.getNextLaunches(count, page * DEFAULT_LAUNCHES_AMOUNT)
}) { entity ->
entity.launches
.map(::transformRocketImageUrl)
.saveLaunches()
.map(mapper::convert)
}
return if (networkResponse is NetworkError) {
launchesDatabase
.launchesDao
.getLaunches(count, page)
.takeUnless { it.isNullOrEmpty() }
?.run {
Success(map(mapper::convert))
} ?: Failure(PersistenceEmptyError())
} else {
networkResponse
}
}
override suspend fun <T : ViewEntity> getLaunchById(
id: Long,
mapper: Mapper<LaunchEntity, T>
): Result<T> {
return launchesDatabase
.launchesDao
.getLaunchById(id)
.takeIf { it != null }
?.run {
Success(mapper.convert(this))
} ?: loadLaunchFromNetwork(id, mapper)
}
private suspend fun <T : ViewEntity> loadLaunchFromNetwork(
id: Long,
mapper: Mapper<LaunchEntity, T>
): Result<T> =
safeExecute({
apiImpl.getLaunchById(id)
}) {
mapper.convert(
transformRocketImageUrl(it)
.saveLaunch()
)
}
private suspend fun List<LaunchEntity>.saveLaunches() = run {
launchesDatabase.launchesDao.saveLaunches(this)
this
}
private suspend fun LaunchEntity.saveLaunch() = run {
launchesDatabase.launchesDao.saveLaunch(this)
this
}
private inline fun <T, R> safeExecute(
block: () -> Response<T>,
transform: (T) -> R
) =
if (isNetworkConnected) {
try {
block().extractResponseBody(transform)
} catch (e: IOException) {
Failure(TimeoutError())
}
} else {
Failure(ConnectionError())
}
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) {
body()?.let {
Success(transform(it))
} ?: Failure(EmptyResultError())
} else {
Failure(ResponseError())
}
private fun transformRocketImageUrl(launch: LaunchEntity) =
if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy(
rocket = launch.rocket.copy(
imageURL = transformImageUrl(
launch.rocket.imageURL,
launch.rocket.imageSizes
)
)
)
} else {
launch
}
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =
try {
val urlSplit = imageUrl.split("_")
val url = urlSplit[0]
val format = urlSplit[1].split(".")[1]
val requestedSize = if (!supportedSizes.contains(DEFAULT_IMAGE_SIZE)) {
supportedSizes.last { it < DEFAULT_IMAGE_SIZE }
} else {
DEFAULT_IMAGE_SIZE
}
"${url}_$requestedSize.$format"
} catch (e: Exception) {
imageUrl
}
//endregion
}

View File

@@ -1,9 +1,8 @@
<resources> <resources>
<string name="reason_generic">Something went wrong</string>
<string name="reason_persistance_empty">There are no saved launches</string>
<string name="reason_network">Network error</string> <string name="reason_network">Network error</string>
<string name="reason_empty_body">Response is empty</string> <string name="reason_empty_body">Response is empty</string>
<string name="reason_generic">Something went wrong</string>
<string name="reason_response">Woops, seems we got a server error</string> <string name="reason_response">Woops, seems we got a server error</string>
<string name="reason_timeout">Server timed out</string> <string name="reason_timeout">Server timed out</string>
<string name="reason_persistance_empty">There are no saved launches</string>
<string name="reason_no_network_persistance_empty">Seems there are no data and network</string>
</resources> </resources>

View File

@@ -1,5 +1,8 @@
package com.melih.repository.interactors.base package com.melih.interactors.base
import com.melih.abstractions.deliverable.Result
import com.melih.abstractions.deliverable.State
import com.melih.abstractions.deliverable.Success
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.spyk import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -10,7 +13,7 @@ import kotlinx.coroutines.runBlocking
import org.amshove.kluent.shouldBeInstanceOf import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.util.* import java.util.ArrayDeque
@UseExperimental(ExperimentalCoroutinesApi::class) @UseExperimental(ExperimentalCoroutinesApi::class)
class BaseInteractorTest { class BaseInteractorTest {

20
data/network/build.gradle Normal file
View File

@@ -0,0 +1,20 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':data:definitions')
implementation libraries.okHttpLogger
implementation libraries.moshiKotlin
implementation libraries.coroutines
implementation libraries.retrofit
testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest
}

View File

21
data/network/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.network" />

View File

@@ -1,7 +1,7 @@
package com.melih.repository.network package com.melih.network
import com.melih.repository.entities.LaunchEntity import com.melih.definitions.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity import com.melih.definitions.entities.LaunchesEntity
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path

View File

@@ -1,7 +1,7 @@
package com.melih.repository.network package com.melih.network
import com.melih.repository.entities.LaunchEntity import com.melih.definitions.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity import com.melih.definitions.entities.LaunchesEntity
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -14,7 +14,7 @@ import javax.inject.Inject
internal const val TIMEOUT_DURATION = 7L internal const val TIMEOUT_DURATION = 7L
internal class ApiImpl @Inject constructor() : Api { class ApiImpl @Inject constructor() : Api {
//region Properties //region Properties

View File

@@ -3,6 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply from: "$rootProject.projectDir/scripts/module.gradle" apply from: "$rootProject.projectDir/scripts/module.gradle"
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle"
android { android {
defaultConfig { defaultConfig {
@@ -17,15 +18,13 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation libraries.coroutines implementation project(':data:definitions')
implementation libraries.liveDataKTX
implementation libraries.retrofit
implementation libraries.room
implementation libraries.moshiKotlin implementation libraries.moshiKotlin
implementation libraries.okHttpLogger implementation libraries.coroutines
implementation libraries.room
kapt annotationProcessors.roomCompiler kapt annotationProcessors.roomCompiler
kapt annotationProcessors.moshi
testImplementation testLibraries.coroutinesCore testImplementation testLibraries.coroutinesCore
testImplementation testLibraries.coroutinesTest testImplementation testLibraries.coroutinesTest

View File

21
data/persistence/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,3 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.melih.persistence" />

View File

@@ -1,15 +1,15 @@
package com.melih.repository.persistence package com.melih.persistence
import android.content.Context import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.melih.repository.entities.LaunchEntity import com.melih.definitions.entities.LaunchEntity
import com.melih.repository.persistence.converters.LocationConverter import com.melih.persistence.converters.LocationConverter
import com.melih.repository.persistence.converters.MissionConverter import com.melih.persistence.converters.MissionConverter
import com.melih.repository.persistence.converters.RocketConverter import com.melih.persistence.converters.RocketConverter
import com.melih.repository.persistence.dao.LaunchesDao import com.melih.persistence.dao.LaunchesDao
const val DB_NAME = "LaunchesDB" const val DB_NAME = "LaunchesDB"
@@ -26,7 +26,7 @@ const val DB_NAME = "LaunchesDB"
RocketConverter::class, RocketConverter::class,
MissionConverter::class MissionConverter::class
) )
internal abstract class LaunchesDatabase : RoomDatabase() { abstract class LaunchesDatabase : RoomDatabase() {
//region Companion //region Companion
@@ -48,6 +48,6 @@ internal abstract class LaunchesDatabase : RoomDatabase() {
//region Abstractions //region Abstractions
internal abstract val launchesDao: LaunchesDao abstract val launchesDao: LaunchesDao
//endregion //endregion
} }

View File

@@ -1,4 +1,4 @@
package com.melih.repository.persistence.converters package com.melih.persistence.converters
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter

View File

@@ -1,4 +1,4 @@
package com.melih.repository.persistence.converters package com.melih.persistence.converters
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter

View File

@@ -1,7 +1,7 @@
package com.melih.repository.persistence.converters package com.melih.persistence.converters
import com.melih.repository.entities.LocationEntity import com.melih.definitions.entities.LocationEntity
import com.melih.repository.entities.LocationEntityJsonAdapter import com.melih.definitions.entities.LocationEntityJsonAdapter
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi

View File

@@ -1,6 +1,6 @@
package com.melih.repository.persistence.converters package com.melih.persistence.converters
import com.melih.repository.entities.MissionEntity import com.melih.definitions.entities.MissionEntity
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.Types import com.squareup.moshi.Types

View File

@@ -1,7 +1,7 @@
package com.melih.repository.persistence.converters package com.melih.persistence.converters
import com.melih.repository.entities.RocketEntity import com.melih.definitions.entities.RocketEntity
import com.melih.repository.entities.RocketEntityJsonAdapter import com.melih.definitions.entities.RocketEntityJsonAdapter
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi

View File

@@ -1,16 +1,16 @@
package com.melih.repository.persistence.dao package com.melih.persistence.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.melih.repository.entities.LaunchEntity import com.melih.definitions.entities.LaunchEntity
/** /**
* DAO for list of [launches][LaunchEntity] * DAO for list of [launches][LaunchEntity]
*/ */
@Dao @Dao
internal abstract class LaunchesDao { abstract class LaunchesDao {
//region Queries //region Queries

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -50,9 +50,29 @@ platform :android do
run_detail_tests() run_detail_tests()
end end
desc "Runs tests in repository module" desc "Runs tests in abstraction module"
lane :test_repository do lane :test_abstractions do
run_repository_tests() run_abstractions_tests()
end
desc "Runs tests in definitions module"
lane :test_definitions do
run_definitions_tests()
end
desc "Runs tests in interactors module"
lane :test_interactors do
run_interactors_tests()
end
desc "Runs tests in network module"
lane :test_network do
run_network_tests()
end
desc "Runs tests in persistence module"
lane :test_persistence do
run_persistence_tests()
end end
# ================ Gradle tasks ================ # ================ Gradle tasks ================
@@ -81,7 +101,23 @@ platform :android do
gradle(task: "features:detail:test --continue") gradle(task: "features:detail:test --continue")
end end
def run_repository_tests def run_abstractions_tests
gradle(task: "repository:test --continue") gradle(task: "abstractions:test --continue")
end
def run_definitions_tests
gradle(task: "data:definitions:test --continue")
end
def run_interactors_tests
gradle(task: "data:interactors:test --continue")
end
def run_network_tests
gradle(task: "data:network:test --continue")
end
def run_persistence_tests
gradle(task: "data:persistence:test --continue")
end end
end end

View File

@@ -14,7 +14,5 @@ android {
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':repository')
testImplementation testLibraries.coroutinesTest testImplementation testLibraries.coroutinesTest
} }

View File

@@ -0,0 +1,10 @@
package com.melih.launches.data
import com.melih.abstractions.data.ViewEntity
data class LaunchDetailItem(
val id: Long,
val imageUrl: String,
val rocketName: String,
val missionDescription: String
) : ViewEntity

View File

@@ -0,0 +1,18 @@
package com.melih.launches.data
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import javax.inject.Inject
class LaunchDetailMapper @Inject constructor() : Mapper<LaunchEntity, LaunchDetailItem>() {
override fun convert(launchEntity: LaunchEntity) =
with(launchEntity) {
LaunchDetailItem(
id,
rocket.imageURL,
rocket.name,
if (!missions.isNullOrEmpty()) missions[0].description else ""
)
}
}

View File

@@ -2,11 +2,15 @@ package com.melih.detail.di.modules
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.melih.abstractions.mapper.Mapper
import com.melih.core.di.keys.ViewModelKey import com.melih.core.di.keys.ViewModelKey
import com.melih.definitions.entities.LaunchEntity
import com.melih.detail.ui.DetailFragment import com.melih.detail.ui.DetailFragment
import com.melih.detail.ui.DetailFragmentArgs import com.melih.detail.ui.DetailFragmentArgs
import com.melih.detail.ui.DetailViewModel import com.melih.detail.ui.DetailViewModel
import com.melih.repository.interactors.GetLaunchDetails import com.melih.interactors.GetLaunchDetails
import com.melih.launches.data.LaunchDetailItem
import com.melih.launches.data.LaunchDetailMapper
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -21,6 +25,9 @@ abstract class DetailFragmentModule {
@IntoMap @IntoMap
@ViewModelKey(DetailViewModel::class) @ViewModelKey(DetailViewModel::class)
abstract fun detailViewModel(detailViewModel: DetailViewModel): ViewModel abstract fun detailViewModel(detailViewModel: DetailViewModel): ViewModel
@Binds
abstract fun detailMapper(mapper: LaunchDetailMapper): Mapper<LaunchEntity, LaunchDetailItem>
//endregion //endregion
@Module @Module

View File

@@ -1,34 +1,31 @@
package com.melih.detail.ui package com.melih.detail.ui
import androidx.lifecycle.Transformations.map import androidx.lifecycle.Transformations.map
import com.melih.abstractions.deliverable.handle
import com.melih.core.base.viewmodel.BaseViewModel import com.melih.core.base.viewmodel.BaseViewModel
import com.melih.repository.entities.LaunchEntity import com.melih.interactors.GetLaunchDetails
import com.melih.repository.interactors.GetLaunchDetails import com.melih.launches.data.LaunchDetailItem
import com.melih.repository.interactors.base.handle import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import javax.inject.Inject import javax.inject.Inject
class DetailViewModel @Inject constructor( class DetailViewModel @Inject constructor(
private val getLaunchDetails: GetLaunchDetails, private val getLaunchDetails: GetLaunchDetails<LaunchDetailItem>,
private val getLaunchDetailsParams: GetLaunchDetails.Params private val getLaunchDetailsParams: GetLaunchDetails.Params
) : BaseViewModel<LaunchEntity>() { ) : BaseViewModel<LaunchDetailItem>() {
//region Properties //region Properties
val rocketName = map(successData) { val rocketName = map(successData) {
it.rocket.name it.rocketName
} }
val description = map(successData) { val description = map(successData) {
if (it.missions.isEmpty()) { it.missionDescription
""
} else {
it.missions[0].description
}
} }
val imageUrl = map(successData) { val imageUrl = map(successData) {
it.rocket.imageURL it.imageUrl
} }
//endregion //endregion

View File

@@ -1,69 +1,69 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout <layout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<data class="DetailBinding"> <data class="DetailBinding">
<variable <variable
name="viewModel" name="viewModel"
type="com.melih.detail.ui.DetailViewModel" /> type="com.melih.detail.ui.DetailViewModel" />
</data> </data>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<ImageView <ImageView
android:id="@+id/imgRocket" android:id="@+id/imgRocket"
imageUrl="@{viewModel.imageUrl}" imageUrl="@{viewModel.imageUrl}"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="220dp" android:layout_height="220dp"
android:layout_marginStart="@dimen/padding_standard" android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard" android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard" android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard" android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard" android:layout_marginRight="@dimen/padding_standard"
android:contentDescription="@string/cd_rocket_image" android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop" android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" /> tools:srcCompat="@tools:sample/avatars[14]" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/tvTitle" android:id="@+id/tvTitle"
style="@style/AppTheme.TextViewStyle.Title" style="@style/AppTheme.TextViewStyle.Title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard" android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard" android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard" android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard" android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard" android:layout_marginRight="@dimen/padding_standard"
android:text="@{viewModel.rocketName}" android:text="@{viewModel.rocketName}"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imgRocket" app:layout_constraintTop_toBottomOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" /> tools:text="@sample/launches.json/launches/name" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/tvDescription" android:id="@+id/tvDescription"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard" android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard" android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard" android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard" android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard" android:layout_marginRight="@dimen/padding_standard"
android:layout_marginBottom="@dimen/padding_standard" android:layout_marginBottom="@dimen/padding_standard"
android:text="@{viewModel.description}" android:text="@{viewModel.description}"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle" app:layout_constraintTop_toBottomOf="@+id/tvTitle"
tools:text="@sample/launches.json/launches/missions/description" /> tools:text="@sample/launches.json/launches/missions/description" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</layout> </layout>

View File

@@ -1,7 +1,8 @@
package com.melih.detail package com.melih.detail
import com.melih.detail.ui.DetailViewModel import com.melih.detail.ui.DetailViewModel
import com.melih.repository.interactors.GetLaunchDetails import com.melih.interactors.GetLaunchDetails
import com.melih.launches.data.LaunchDetailItem
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot import io.mockk.slot
import io.mockk.spyk import io.mockk.spyk
@@ -19,7 +20,7 @@ import org.junit.jupiter.api.Test
@UseExperimental(ExperimentalCoroutinesApi::class) @UseExperimental(ExperimentalCoroutinesApi::class)
class DetailViewModelTest : BaseTestWithMainThread() { class DetailViewModelTest : BaseTestWithMainThread() {
private val getLaunchDetails: GetLaunchDetails = mockk(relaxed = true) private val getLaunchDetails: GetLaunchDetails<LaunchDetailItem> = mockk(relaxed = true)
private val getLaunchDetailsParams = GetLaunchDetails.Params(1013) private val getLaunchDetailsParams = GetLaunchDetails.Params(1013)
private val viewModel = spyk(DetailViewModel(getLaunchDetails, getLaunchDetailsParams)) private val viewModel = spyk(DetailViewModel(getLaunchDetails, getLaunchDetailsParams))

View File

@@ -7,8 +7,6 @@ apply from: "$rootProject.projectDir/scripts/feature_module.gradle"
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':repository')
implementation libraries.paging implementation libraries.paging
implementation libraries.swipeRefreshLayout implementation libraries.swipeRefreshLayout

View File

@@ -0,0 +1,10 @@
package com.melih.launches.data
import com.melih.abstractions.data.ViewEntity
data class LaunchItem(
val id: Long,
val imageUrl: String,
val rocketName: String,
val missionDescription: String
) : ViewEntity

View File

@@ -0,0 +1,18 @@
package com.melih.launches.data
import com.melih.abstractions.mapper.Mapper
import com.melih.definitions.entities.LaunchEntity
import javax.inject.Inject
class LaunchMapper @Inject constructor() : Mapper<LaunchEntity, LaunchItem>() {
override fun convert(launchEntity: LaunchEntity) =
with(launchEntity) {
LaunchItem(
id,
rocket.imageURL,
rocket.name,
if (!missions.isNullOrEmpty()) missions[0].description else ""
)
}
}

View File

@@ -2,10 +2,14 @@ package com.melih.launches.di.modules
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.paging.Config import androidx.paging.Config
import com.melih.abstractions.mapper.Mapper
import com.melih.core.di.keys.ViewModelKey import com.melih.core.di.keys.ViewModelKey
import com.melih.definitions.entities.LaunchEntity
import com.melih.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.interactors.GetLaunches
import com.melih.launches.data.LaunchItem
import com.melih.launches.data.LaunchMapper
import com.melih.launches.ui.vm.LaunchesViewModel import com.melih.launches.ui.vm.LaunchesViewModel
import com.melih.repository.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.GetLaunches
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -14,12 +18,15 @@ import dagger.multibindings.IntoMap
@Module @Module
abstract class LaunchesFragmentModule { abstract class LaunchesFragmentModule {
//region ViewModels //region Binds
@Binds @Binds
@IntoMap @IntoMap
@ViewModelKey(LaunchesViewModel::class) @ViewModelKey(LaunchesViewModel::class)
abstract fun listViewModel(listViewModel: LaunchesViewModel): ViewModel abstract fun launchesViewModel(listViewModel: LaunchesViewModel): ViewModel
@Binds
abstract fun launchMapper(mapper: LaunchMapper): Mapper<LaunchEntity, LaunchItem>
//endregion //endregion
@Module @Module

View File

@@ -4,18 +4,18 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.melih.abstractions.deliverable.State
import com.melih.core.actions.openDetail import com.melih.core.actions.openDetail
import com.melih.core.base.lifecycle.BaseDaggerFragment import com.melih.core.base.lifecycle.BaseDaggerFragment
import com.melih.core.extensions.observe import com.melih.core.extensions.observe
import com.melih.interactors.error.PersistenceEmptyError
import com.melih.launches.R import com.melih.launches.R
import com.melih.launches.databinding.ListBinding import com.melih.launches.data.LaunchItem
import com.melih.launches.databinding.LaunchesBinding
import com.melih.launches.ui.adapters.LaunchesAdapter import com.melih.launches.ui.adapters.LaunchesAdapter
import com.melih.launches.ui.vm.LaunchesViewModel import com.melih.launches.ui.vm.LaunchesViewModel
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.State
class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.OnRefreshListener { class LaunchesFragment : BaseDaggerFragment<LaunchesBinding>(), SwipeRefreshLayout.OnRefreshListener {
//region Properties //region Properties
@@ -67,7 +67,7 @@ class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.O
// Observing error to show toast with retry action // Observing error to show toast with retry action
observe(viewModel.errorData) { observe(viewModel.errorData) {
if (it !is PersistenceEmpty) { if (it !is PersistenceEmptyError) {
showSnackbarWithAction(it) { showSnackbarWithAction(it) {
viewModel.retry() viewModel.retry()
} }
@@ -79,7 +79,7 @@ class LaunchesFragment : BaseDaggerFragment<ListBinding>(), SwipeRefreshLayout.O
} }
} }
private fun onItemSelected(item: LaunchEntity) { private fun onItemSelected(item: LaunchItem) {
openDetail(item.id) openDetail(item.id)
} }

View File

@@ -5,10 +5,10 @@ import android.view.ViewGroup
import com.melih.core.base.recycler.BasePagingListAdapter import com.melih.core.base.recycler.BasePagingListAdapter
import com.melih.core.base.recycler.BaseViewHolder import com.melih.core.base.recycler.BaseViewHolder
import com.melih.core.extensions.createDiffCallback import com.melih.core.extensions.createDiffCallback
import com.melih.launches.data.LaunchItem
import com.melih.launches.databinding.LaunchRowBinding import com.melih.launches.databinding.LaunchRowBinding
import com.melih.repository.entities.LaunchEntity
class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BasePagingListAdapter<LaunchEntity>( class LaunchesAdapter(itemClickListener: (LaunchItem) -> Unit) : BasePagingListAdapter<LaunchItem>(
createDiffCallback { oldItem, newItem -> oldItem.id == newItem.id }, createDiffCallback { oldItem, newItem -> oldItem.id == newItem.id },
itemClickListener itemClickListener
) { ) {
@@ -19,21 +19,18 @@ class LaunchesAdapter(itemClickListener: (LaunchEntity) -> Unit) : BasePagingLis
inflater: LayoutInflater, inflater: LayoutInflater,
parent: ViewGroup, parent: ViewGroup,
viewType: Int viewType: Int
): BaseViewHolder<LaunchEntity> = ): BaseViewHolder<LaunchItem> =
LaunchesViewHolder(LaunchRowBinding.inflate(inflater, parent, false)) LaunchesViewHolder(LaunchRowBinding.inflate(inflater, parent, false))
//endregion //endregion
} }
class LaunchesViewHolder(private val binding: LaunchRowBinding) : BaseViewHolder<LaunchEntity>(binding) { class LaunchesViewHolder(private val binding: LaunchRowBinding) :
BaseViewHolder<LaunchItem>(binding) {
//region Functions //region Functions
override fun bind(item: LaunchEntity) { override fun bind(item: LaunchItem) {
binding.entity = item binding.entity = item
val missions = item.missions
binding.tvDescription.text = if (!missions.isNullOrEmpty()) missions[0].description else ""
binding.executePendingBindings() binding.executePendingBindings()
} }
//endregion //endregion

View File

@@ -1,25 +1,23 @@
package com.melih.launches.ui.paging package com.melih.launches.ui.paging
import com.melih.core.base.paging.BasePagingDataSource import com.melih.core.base.paging.BasePagingDataSource
import com.melih.repository.entities.LaunchEntity import com.melih.interactors.GetLaunches
import com.melih.repository.interactors.GetLaunches import com.melih.launches.data.LaunchItem
import com.melih.repository.interactors.base.Result
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
/** /**
* Uses [GetLaunches] to get data for pagination * Uses [GetLaunches] to get data for pagination
*/ */
class LaunchesPagingSource @Inject constructor( class LaunchesPagingSource @Inject constructor(
private val getLaunches: GetLaunches, private val getLaunches: GetLaunches<LaunchItem>,
private val getLaunchesParams: GetLaunches.Params private val getLaunchesParams: GetLaunches.Params
) : BasePagingDataSource<LaunchEntity>() { ) : BasePagingDataSource<LaunchItem>() {
//region Functions //region Functions
@UseExperimental(ExperimentalCoroutinesApi::class) @UseExperimental(ExperimentalCoroutinesApi::class)
override fun loadDataForPage(page: Int): Flow<Result<List<LaunchEntity>>> = override fun loadDataForPage(page: Int) =
getLaunches( getLaunches(
getLaunchesParams.copy( getLaunchesParams.copy(
page = page page = page

View File

@@ -2,16 +2,16 @@ package com.melih.launches.ui.paging
import com.melih.core.base.paging.BasePagingDataSource import com.melih.core.base.paging.BasePagingDataSource
import com.melih.core.base.paging.BasePagingFactory import com.melih.core.base.paging.BasePagingFactory
import com.melih.repository.entities.LaunchEntity import com.melih.launches.data.LaunchItem
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
class LaunchesPagingSourceFactory @Inject constructor( class LaunchesPagingSourceFactory @Inject constructor(
private val sourceProvider: Provider<LaunchesPagingSource> private val sourceProvider: Provider<LaunchesPagingSource>
) : BasePagingFactory<LaunchEntity>() { ) : BasePagingFactory<LaunchItem>() {
//region Functions //region Functions
override fun createSource(): BasePagingDataSource<LaunchEntity> = sourceProvider.get() override fun createSource(): BasePagingDataSource<LaunchItem> = sourceProvider.get()
//endregion //endregion
} }

View File

@@ -3,18 +3,18 @@ package com.melih.launches.ui.vm
import androidx.paging.PagedList import androidx.paging.PagedList
import com.melih.core.base.paging.BasePagingFactory import com.melih.core.base.paging.BasePagingFactory
import com.melih.core.base.viewmodel.BasePagingViewModel import com.melih.core.base.viewmodel.BasePagingViewModel
import com.melih.launches.data.LaunchItem
import com.melih.launches.ui.paging.LaunchesPagingSourceFactory import com.melih.launches.ui.paging.LaunchesPagingSourceFactory
import com.melih.repository.entities.LaunchEntity
import javax.inject.Inject import javax.inject.Inject
class LaunchesViewModel @Inject constructor( class LaunchesViewModel @Inject constructor(
private val launchesPagingSourceFactory: LaunchesPagingSourceFactory, private val launchesPagingSourceFactory: LaunchesPagingSourceFactory,
private val launchesPagingConfig: PagedList.Config private val launchesPagingConfig: PagedList.Config
) : BasePagingViewModel<LaunchEntity>() { ) : BasePagingViewModel<LaunchItem>() {
//region Properties //region Properties
override val factory: BasePagingFactory<LaunchEntity> override val factory: BasePagingFactory<LaunchItem>
get() = launchesPagingSourceFactory get() = launchesPagingSourceFactory
override val config: PagedList.Config override val config: PagedList.Config

View File

@@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<data class="ListBinding"> <data class="LaunchesBinding">
<variable <variable
name="viewModel" name="viewModel"

View File

@@ -1,69 +1,70 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout <layout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<data class="LaunchRowBinding"> <data class="LaunchRowBinding">
<variable <variable
name="entity" name="entity"
type="com.melih.repository.entities.LaunchEntity" /> type="com.melih.launches.data.LaunchItem" />
</data> </data>
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard" android:layout_marginStart="@dimen/padding_standard"
android:layout_marginLeft="@dimen/padding_standard" android:layout_marginLeft="@dimen/padding_standard"
android:layout_marginTop="@dimen/padding_standard" android:layout_marginTop="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_standard" android:layout_marginEnd="@dimen/padding_standard"
android:layout_marginRight="@dimen/padding_standard" android:layout_marginRight="@dimen/padding_standard"
android:background="@null" android:background="@null"
app:cardElevation="10dp"> app:cardElevation="10dp">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="160dp"> android:layout_height="160dp">
<ImageView <ImageView
android:id="@+id/imgRocket" android:id="@+id/imgRocket"
imageUrl="@{entity.rocket.imageURL}" imageUrl="@{entity.imageUrl}"
android:layout_width="120dp" android:layout_width="120dp"
android:layout_height="120dp" android:layout_height="120dp"
android:layout_marginStart="@dimen/padding_large" android:layout_marginStart="@dimen/padding_large"
android:contentDescription="@string/cd_rocket_image" android:contentDescription="@string/cd_rocket_image"
android:scaleType="centerCrop" android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars[14]" /> tools:srcCompat="@tools:sample/avatars[14]" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/tvTitle" android:id="@+id/tvTitle"
style="@style/AppTheme.TextViewStyle.Title" style="@style/AppTheme.TextViewStyle.Title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="@dimen/padding_standard" android:layout_marginStart="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_large" android:layout_marginEnd="@dimen/padding_large"
android:text="@{entity.name}" android:text="@{entity.rocketName}"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imgRocket" app:layout_constraintStart_toEndOf="@+id/imgRocket"
app:layout_constraintTop_toTopOf="@+id/imgRocket" app:layout_constraintTop_toTopOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/name" /> tools:text="@sample/launches.json/launches/name" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/tvDescription" android:id="@+id/tvDescription"
style="@style/AppTheme.TextViewStyle.Description" style="@style/AppTheme.TextViewStyle.Description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginStart="@dimen/padding_standard" android:layout_marginStart="@dimen/padding_standard"
android:layout_marginEnd="@dimen/padding_large" android:layout_marginEnd="@dimen/padding_large"
app:layout_constraintBottom_toBottomOf="@+id/imgRocket" android:text="@{entity.missionDescription}"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintBottom_toBottomOf="@+id/imgRocket"
app:layout_constraintStart_toEndOf="@+id/imgRocket" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvTitle" app:layout_constraintStart_toEndOf="@+id/imgRocket"
tools:text="@sample/launches.json/launches/missions/description" /> app:layout_constraintTop_toBottomOf="@+id/tvTitle"
</androidx.constraintlayout.widget.ConstraintLayout> tools:text="@sample/launches.json/launches/missions/description" />
</com.google.android.material.card.MaterialCardView> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout> </layout>

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,2 +0,0 @@
<manifest package="com.melih.repository" />

View File

@@ -1,16 +0,0 @@
package com.melih.repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Result
/**
* Contract for sources to seperate low level business logic from build and return type
*/
abstract class Repository {
//region Abstractions
internal abstract suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>>
internal abstract suspend fun getLaunchById(id: Long): Result<LaunchEntity>
//endregion
}

View File

@@ -1,55 +0,0 @@
package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
/**
* Gets next given number of launches
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
class GetLaunchDetails @Inject constructor() : BaseInteractor<LaunchEntity, GetLaunchDetails.Params>() {
//region Properties
@field:Inject
internal lateinit var networkSource: NetworkSource
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<LaunchEntity>>.run(params: Params) {
val result = persistenceSource.getLaunchById(params.id)
if (result !is Success) {
when (val response = networkSource.getLaunchById(params.id)) {
// Save result and return again from persistence
is Success -> {
persistenceSource.saveLaunch(response.successData)
emit(persistenceSource.getLaunchById(params.id))
}
// Redirect failure as it is
is Failure -> emit(response)
}
} else {
emit(result)
}
}
//endregion
data class Params(
val id: Long
) : InteractorParameters
}

View File

@@ -1,54 +0,0 @@
package com.melih.repository.interactors
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.BaseInteractor
import com.melih.repository.interactors.base.InteractorParameters
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.sources.NetworkSource
import com.melih.repository.sources.PersistenceSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import javax.inject.Inject
const val DEFAULT_LAUNCHES_AMOUNT = 15
/**
* Gets next given number of launches
*/
@UseExperimental(ExperimentalCoroutinesApi::class)
class GetLaunches @Inject constructor() : BaseInteractor<List<LaunchEntity>, GetLaunches.Params>() {
//region Properties
@field:Inject
internal lateinit var networkSource: NetworkSource
@field:Inject
internal lateinit var persistenceSource: PersistenceSource
//endregion
//region Functions
override suspend fun FlowCollector<Result<List<LaunchEntity>>>.run(params: Params) {
// Start network fetch - we're not handling state here to ommit them
networkSource
.getNextLaunches(params.count, params.page)
.also {
if (it is Success) {
persistenceSource.saveLaunches(it.successData)
emit(persistenceSource.getNextLaunches(params.count, params.page))
} else {
emit(it)
emit(persistenceSource.getNextLaunches(params.count, params.page))
}
}
}
//endregion
data class Params(
val count: Int = DEFAULT_LAUNCHES_AMOUNT,
val page: Int
) : InteractorParameters
}

View File

@@ -1,21 +0,0 @@
package com.melih.repository.interactors.base
import androidx.annotation.StringRes
import com.melih.repository.R
/**
* [Failure] reasons
*/
sealed class Reason(@StringRes val messageRes: Int)
//region Subclasses
class NetworkError : Reason(R.string.reason_network)
class EmptyResultError : Reason(R.string.reason_empty_body)
class GenericError : Reason(R.string.reason_generic)
class ResponseError : Reason(R.string.reason_response)
class TimeoutError : Reason(R.string.reason_timeout)
class PersistenceEmpty : Reason(R.string.reason_persistance_empty)
class NoNetworkPersistenceEmpty : Reason(R.string.reason_no_network_persistance_empty)
//endregion

View File

@@ -1,118 +0,0 @@
package com.melih.repository.sources
import android.net.NetworkInfo
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.DEFAULT_LAUNCHES_AMOUNT
import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.Reason
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.TimeoutError
import com.melih.repository.network.ApiImpl
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Provider
private const val DEFAULT_IMAGE_SIZE = 480
/**
* NetworkSource for fetching results using api and wrapping them as contracted in [repository][Repository],
* returning either [failure][Failure] with proper [reason][Reason] or [success][Success] with data
*/
internal class NetworkSource @Inject constructor(
private val apiImpl: ApiImpl,
private val networkInfoProvider: Provider<NetworkInfo>
) : Repository() {
//region Properties
private val isNetworkConnected: Boolean
get() {
val networkInfo = networkInfoProvider.get()
return networkInfo != null && networkInfo.isConnected
}
//endregion
//region Functions
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
safeExecute({
apiImpl.getNextLaunches(count, page * DEFAULT_LAUNCHES_AMOUNT)
}) { entity ->
entity.launches.map { launch ->
if (!launch.rocket.imageURL.isNotBlank()) {
launch.copy(
rocket = launch.rocket.copy(
imageURL = transformImageUrl(
launch.rocket.imageURL,
launch.rocket.imageSizes
)
)
)
} else {
launch
}
}
}
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
safeExecute({
apiImpl.getLaunchById(id)
}) {
if (!it.rocket.imageURL.isNotBlank()) {
it.copy(
rocket = it.rocket.copy(
imageURL = transformImageUrl(it.rocket.imageURL, it.rocket.imageSizes)
)
)
} else {
it
}
}
private inline fun <T, R> safeExecute(
block: () -> Response<T>,
transform: (T) -> R
) =
if (isNetworkConnected) {
try {
block().extractResponseBody(transform)
} catch (e: IOException) {
Failure(TimeoutError())
}
} else {
Failure(NetworkError())
}
private inline fun <T, R> Response<T>.extractResponseBody(transform: (T) -> R) =
if (isSuccessful) {
body()?.let {
Success(transform(it))
} ?: Failure(EmptyResultError())
} else {
Failure(ResponseError())
}
private fun transformImageUrl(imageUrl: String, supportedSizes: IntArray) =
try {
val urlSplit = imageUrl.split("_")
val url = urlSplit[0]
val format = urlSplit[1].split(".")[1]
val requestedSize = if (!supportedSizes.contains(DEFAULT_IMAGE_SIZE)) {
supportedSizes.last { it < DEFAULT_IMAGE_SIZE }
} else {
DEFAULT_IMAGE_SIZE
}
"${url}_$requestedSize.$format"
} catch (e: Exception) {
imageUrl
}
//endregion
}

View File

@@ -1,50 +0,0 @@
package com.melih.repository.sources
import android.content.Context
import com.melih.repository.Repository
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Result
import com.melih.repository.interactors.base.Success
import com.melih.repository.persistence.LaunchesDatabase
import javax.inject.Inject
/**
* Persistance source using Room database to save / read objects for SST - offline usage
*/
internal class PersistenceSource @Inject constructor(
ctx: Context
) : Repository() {
//region Functions
private val launchesDatabase = LaunchesDatabase.getInstance(ctx)
override suspend fun getNextLaunches(count: Int, page: Int): Result<List<LaunchEntity>> =
launchesDatabase
.launchesDao
.getLaunches(count, page)
.takeIf { it.isNotEmpty() }
?.run {
Success(this)
} ?: Failure(PersistenceEmpty())
override suspend fun getLaunchById(id: Long): Result<LaunchEntity> =
launchesDatabase
.launchesDao
.getLaunchById(id)
.takeIf { it != null }
?.run {
Success(this)
} ?: Failure(PersistenceEmpty())
internal suspend fun saveLaunches(launches: List<LaunchEntity>) {
launchesDatabase.launchesDao.saveLaunches(launches)
}
internal suspend fun saveLaunch(launch: LaunchEntity) {
launchesDatabase.launchesDao.saveLaunch(launch)
}
//endregion
}

View File

@@ -1,104 +0,0 @@
package com.melih.repository.sources
import android.net.NetworkInfo
import com.melih.repository.R
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.entities.LaunchesEntity
import com.melih.repository.interactors.base.EmptyResultError
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.NetworkError
import com.melih.repository.interactors.base.ResponseError
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.network.ApiImpl
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runBlockingTest
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import javax.inject.Provider
@UseExperimental(ExperimentalCoroutinesApi::class)
class NetworkSourceTest {
private val apiImpl = mockk<ApiImpl>(relaxed = true)
private val networkInfoProvider = mockk<Provider<NetworkInfo>>(relaxed = true) {
every { get() } returns mockk(relaxed = true)
}
private val source = spyk(NetworkSource(apiImpl, networkInfoProvider))
@Nested
inner class GetNextLaunches {
@Test
fun `should return network error when internet is not connected`() {
every { networkInfoProvider.get().isConnected } returns false
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf NetworkError::class
}
}
}
@Test
fun `should return response error when it is not successful`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns false
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf ResponseError::class
(it as ResponseError).messageRes shouldEqualTo R.string.reason_response
}
}
}
@Test
fun `should return empty result error when body is null`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns null
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf EmptyResultError::class
}
}
}
@Test
fun `should return success with data if execution is successful`() {
every { networkInfoProvider.get().isConnected } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).isSuccessful } returns true
coEvery { apiImpl.getNextLaunches(any(), any()).body() } returns LaunchesEntity(launches = listOf(LaunchEntity(id = 1013)))
runBlockingTest {
val result = source.getNextLaunches(1, 0)
result shouldBeInstanceOf Success::class
result.onSuccess {
it shouldBeInstanceOf List::class
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
}
}

View File

@@ -1,73 +0,0 @@
package com.melih.repository.sources
import android.content.Context
import com.melih.repository.entities.LaunchEntity
import com.melih.repository.interactors.base.Failure
import com.melih.repository.interactors.base.PersistenceEmpty
import com.melih.repository.interactors.base.Success
import com.melih.repository.interactors.base.onFailure
import com.melih.repository.interactors.base.onSuccess
import com.melih.repository.persistence.LaunchesDatabase
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.spyk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeInstanceOf
import org.amshove.kluent.shouldEqualTo
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
class PersistanceSourceTest {
private val ctx = mockk<Context>(relaxed = true)
private val dbImplementation = mockk<LaunchesDatabase>(relaxed = true)
private val source = spyk(PersistenceSource(ctx))
private val scope = CoroutineScope(Dispatchers.IO)
@BeforeEach
fun setup() {
mockkObject(LaunchesDatabase)
every { LaunchesDatabase.getInstance(ctx) } returns dbImplementation
}
@Nested
inner class GetNextLaunches {
@Test
fun `should return persistance empty error when db is empty`() {
coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns emptyList()
scope.launch {
val result = source.getNextLaunches(10, 0)
result shouldBeInstanceOf Failure::class
result.onFailure {
it shouldBeInstanceOf PersistenceEmpty::class
}
}
}
@Test
fun `should return success with data if db is not empty`() {
coEvery { dbImplementation.launchesDao.getLaunches(any(), any()) } returns listOf(LaunchEntity(id = 1013))
scope.launch {
val result = source.getNextLaunches(10, 0)
result shouldBeInstanceOf Success::class
result.onSuccess {
it.isEmpty() shouldBe false
it.size shouldEqualTo 1
it[0].id shouldEqualTo 1013
}
}
}
}
}

53
scripts/cq/jacoco.gradle Normal file
View File

@@ -0,0 +1,53 @@
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.1"
reportsDir = file("$rootProject.projectDir/reports/jacoco/$project.name")
}
task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
group = "Reporting"
description = "Generate Jacoco coverage reports for Debug build"
reports {
xml.enabled = true
html.enabled = true
}
// what to exclude from coverage report
// UI, "noise", generated classes, platform classes, etc.
def excludes = [
'**/R.class',
'**/R$*.class',
'**/*$ViewInjector*.*',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'android/**/*.*',
'**/*Fragment.*',
'**/*Activity.*'
]
// generated classes
getClassDirectories().setFrom(
fileTree(
dir: "$buildDir/intermediates/classes/debug",
excludes: excludes
) + fileTree(
dir: "$buildDir/tmp/kotlin-classes/debug",
excludes: excludes
)
)
// sources
getSourceDirectories().setFrom(
files([
android.sourceSets.main.java.srcDirs,
"src/main/kotlin"
])
)
getExecutionData().setFrom(
files("$buildDir/jacoco/testDebugUnitTest.exec")
)
}

View File

@@ -1,4 +1,6 @@
apply from: "$rootProject.projectDir/scripts/default_dependencies.gradle" apply from: "$rootProject.projectDir/scripts/cq/detekt.gradle"
apply from: "$rootProject.projectDir/scripts/cq/dokka.gradle"
apply from: "$rootProject.projectDir/scripts/cq/jacoco.gradle"
android { android {
compileSdkVersion versions.compileSdkVersion compileSdkVersion versions.compileSdkVersion

View File

@@ -1,11 +1,10 @@
apply plugin: "de.mannodermaus.android-junit5" apply plugin: "de.mannodermaus.android-junit5"
apply from: "$rootProject.projectDir/scripts/detekt.gradle"
apply from: "$rootProject.projectDir/scripts/dokka.gradle"
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':abstractions')
implementation libraries.kotlin implementation libraries.kotlin
implementation libraries.dagger implementation libraries.dagger
implementation libraries.timber implementation libraries.timber
@@ -18,57 +17,3 @@ dependencies {
testRuntimeOnly testLibraries.jUnitEngine testRuntimeOnly testLibraries.jUnitEngine
} }
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.1"
reportsDir = file("$rootProject.projectDir/reports/jacoco/$project.name")
}
task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
group = "Reporting"
description = "Generate Jacoco coverage reports for Debug build"
reports {
xml.enabled = true
html.enabled = true
}
// what to exclude from coverage report
// UI, "noise", generated classes, platform classes, etc.
def excludes = [
'**/R.class',
'**/R$*.class',
'**/*$ViewInjector*.*',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*Test*.*',
'android/**/*.*',
'**/*Fragment.*',
'**/*Activity.*'
]
// generated classes
getClassDirectories().setFrom(
fileTree(
dir: "$buildDir/intermediates/classes/debug",
excludes: excludes
) + fileTree(
dir: "$buildDir/tmp/kotlin-classes/debug",
excludes: excludes
)
)
// sources
getSourceDirectories().setFrom(
files([
android.sourceSets.main.java.srcDirs,
"src/main/kotlin"
])
)
getExecutionData().setFrom(
files("$buildDir/jacoco/testDebugUnitTest.exec")
)
}

Some files were not shown because too many files have changed in this diff Show More