diff --git a/.gitignore b/.gitignore index 5bd175f..f55d609 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /.idea/assetWizardSettings.xml .DS_Store /build +**/build /captures .externalNativeBuild .cxx diff --git a/README.md b/README.md index 5fae070..11e1ba4 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,39 @@ Using this endpoint, show a list of these items, with each row displaying at lea Bonus Points: - Unit tests + +# The Solution: +## Project configuration +The project was refactored to a multi-module structure that can be used for a big, scalable application. +Currently it contains the following modules: +- app - the application module that holds the main Dagger graph, main activity and navigation graph +- component/base - module that can be shared with other modules that contains base application configuration, e.g. the network layer +- component/common - reusable general utilities, not coupled to the app business logic +- feature/books-list - Fragment that contains the books list + +Changes in the project Gradle file were done only when necessary to support the new project structure. +When I create a project setup from scratch, I usually do a lot of things differently, e.g.: +- configure detekt +- configure code coverage measurement and enforcement +- update dependencies version +- use KSP instead of KAPT +- use src/main/kotlin and src/test/kotlin directories +- use Junit5 instead of Junit4 + +The Gradle files still may contain some code that should be cleaned up, e.g. unused dependencies should be removed and the order of dependencies should be updated (e.g. by sorting the alphabetically). +The project currently supports only debug build type. The release build type and code obfuscation have not been configured. + +## MVP architecture +The presenter was created from scratch in a very simple way. It has a lifecycle connected to the Fragment lifecycle, so doesn't survive screen rotation. +It would be possible to make it live on the extended lifecycle scope, e.g. with using the 'https://github.com/konmik/nucleus' library that I used in the past, but it has been deprecated 5 years ago. +The presenter also currently doesn't support saving the state in a Bundle or SavedStateHandle. + +## Libraries +I have used some additional "AndroidX" libraries, "Kluent" and "Mockito" for testing, "Moshi" for JSON parsing and kotlin coroutines for asynchronous operations. Using coroutines instead of RxJava should be recommended for new projects for better work with Android frameworks (including Compose), support for nullable values in streams and better error handling. + +## UI +A very minimal time was spent on UI. It was designed just to be functional. +The whole list is loaded at once without pagination support. + +## Tests +Due to the limited time spent on the project, only unit tests were created (but still some tests missing) and no integration tests. \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 0d0fa1b..3998cdf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,16 +6,9 @@ plugins { android { namespace 'com.example.otchallenge' - compileSdk 34 defaultConfig { applicationId "com.example.otchallenge" - minSdk 24 - targetSdk 34 - versionCode 1 - versionName "1.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -24,37 +17,32 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' + + buildFeatures { + // if it's not enabled on the app module, the app crashes with + // ClassNotFoundException: Didn't find class "androidx.databinding.DataBinderMapperImpl + dataBinding = true } } dependencies { + implementation project(":component:base") + implementation project(":feature:books-list") implementation libs.androidx.core.ktx implementation libs.androidx.appcompat implementation libs.material implementation libs.androidx.activity implementation libs.androidx.constraintlayout + implementation libs.androidx.navigation.fragment.ktx + implementation libs.androidx.navigation.ui.ktx + // dagger implementation libs.dagger kapt libs.dagger.compiler //retrofit implementation libs.retrofit - implementation libs.retrofit.rx.adapter - - //glide - implementation libs.glide - - //reactive x - implementation libs.rx.android - implementation libs.rx.java - implementation libs.rx.kotlin testImplementation libs.junit androidTestImplementation libs.androidx.junit diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a18c7a..4d287d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,26 +1,29 @@ + xmlns:tools="http://schemas.android.com/tools"> - - - - + - - - - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/MainActivity.kt b/app/src/main/java/com/example/otchallenge/MainActivity.kt index d35da32..3437937 100644 --- a/app/src/main/java/com/example/otchallenge/MainActivity.kt +++ b/app/src/main/java/com/example/otchallenge/MainActivity.kt @@ -5,18 +5,35 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.NavigationUI.setupActionBarWithNavController class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - (application as MyApplication).appComponent.inject(this) - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - } + private lateinit var navController: NavController + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContentView(R.layout.activity_main) + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + navController = findNavController() + setupActionBarWithNavController(this, navController) + } + + private fun findNavController(): NavController { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + return navHostFragment.navController + } + + override fun onSupportNavigateUp(): Boolean { + return navController.navigateUp() + } } diff --git a/app/src/main/java/com/example/otchallenge/MyApplication.kt b/app/src/main/java/com/example/otchallenge/MyApplication.kt index ba66e70..f5770d0 100644 --- a/app/src/main/java/com/example/otchallenge/MyApplication.kt +++ b/app/src/main/java/com/example/otchallenge/MyApplication.kt @@ -1,15 +1,23 @@ package com.example.otchallenge import android.app.Application +import com.example.otchallenge.bookslist.api.BooksListComponentProvider +import com.example.otchallenge.bookslist.internal.di.BooksListComponent import com.example.otchallenge.di.AppComponent +import com.example.otchallenge.di.AppModule import com.example.otchallenge.di.DaggerAppComponent -class MyApplication : Application() { +class MyApplication : Application(), BooksListComponentProvider { - lateinit var appComponent: AppComponent + private lateinit var appComponent: AppComponent - override fun onCreate() { - super.onCreate() - appComponent = DaggerAppComponent.builder().build() - } + override fun onCreate() { + super.onCreate() + appComponent = DaggerAppComponent.builder() + .appModule(AppModule(this)) + .build() + } + + override fun provideBooksListComponent(): BooksListComponent.Builder = + appComponent.booksListComponent() } diff --git a/app/src/main/java/com/example/otchallenge/di/AppComponent.kt b/app/src/main/java/com/example/otchallenge/di/AppComponent.kt index d3c83c6..a9ef59d 100644 --- a/app/src/main/java/com/example/otchallenge/di/AppComponent.kt +++ b/app/src/main/java/com/example/otchallenge/di/AppComponent.kt @@ -1,11 +1,18 @@ -package com.example.otchallenge.di - -import com.example.otchallenge.MainActivity -import dagger.Component -import javax.inject.Singleton - -@Singleton -@Component -interface AppComponent { - fun inject(activity: MainActivity) -} +package com.example.otchallenge.di + +import com.example.otchallenge.base.internal.di.BaseModule +import com.example.otchallenge.bookslist.internal.di.BooksListComponent +import dagger.Component +import javax.inject.Singleton + +@Singleton +@Component( + modules = [ + AppModule::class, + BaseModule::class, + SubcomponentsModule::class, + ] +) +interface AppComponent { + fun booksListComponent(): BooksListComponent.Builder +} diff --git a/app/src/main/java/com/example/otchallenge/di/AppModule.kt b/app/src/main/java/com/example/otchallenge/di/AppModule.kt new file mode 100644 index 0000000..9a6b452 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/AppModule.kt @@ -0,0 +1,15 @@ +package com.example.otchallenge.di + +import android.app.Application +import android.content.Context +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +internal class AppModule(private val application: Application) { + + @Provides + @Singleton + fun provideContext(): Context = application +} diff --git a/app/src/main/java/com/example/otchallenge/di/SubcomponentsModule.kt b/app/src/main/java/com/example/otchallenge/di/SubcomponentsModule.kt new file mode 100644 index 0000000..17e4918 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/SubcomponentsModule.kt @@ -0,0 +1,10 @@ +package com.example.otchallenge.di + +import com.example.otchallenge.bookslist.internal.di.BooksListComponent +import dagger.Module + +/*** + * this is a module which will register all the sub-components used in the project + */ +@Module(subcomponents = [BooksListComponent::class]) +interface SubcomponentsModule \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c7a2d54..b918228 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,22 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/main" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".MainActivity"> - + - \ No newline at end of file + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..61b9256 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 36fb62f..bf1bbca 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,6 +1,6 @@ - diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index fa5fc6f..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - #FF000000 - #FFFFFFFF - \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c9c411a..7fd6d63 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,6 +1,6 @@ - diff --git a/app/src/test/java/com/example/otchallenge/ExampleUnitTest.kt b/app/src/test/java/com/example/otchallenge/ExampleUnitTest.kt deleted file mode 100644 index 1d6b8fb..0000000 --- a/app/src/test/java/com/example/otchallenge/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.otchallenge - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3d54f54..61ab989 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,44 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.kapt) apply false +} + +subprojects { + afterEvaluate { + if (project.hasProperty('android')) { + android { + compileSdkVersion 34 + + lintOptions { + abortOnError true + warningsAsErrors true + + // there can fail when a new version of dependency is released, so any time, with no code changes + informational 'AndroidGradlePluginVersion' + informational 'GradleDependency' + } + + defaultConfig { + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = '11' + allWarningsAsErrors = true + } + } + } + } } \ No newline at end of file diff --git a/component/base/build.gradle b/component/base/build.gradle new file mode 100644 index 0000000..b994f7f --- /dev/null +++ b/component/base/build.gradle @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kapt) +} + +android { + namespace 'com.example.otchallenge.base' +} + +dependencies { + api libs.moshi.moshi + + // dagger + implementation libs.dagger + kapt libs.dagger.compiler + + //retrofit + implementation libs.retrofit + implementation libs.retrofit.converter.moshi +} \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/api/di/FragmentScope.kt b/component/base/src/main/java/com/example/otchallenge/base/api/di/FragmentScope.kt new file mode 100644 index 0000000..4b0feb0 --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/api/di/FragmentScope.kt @@ -0,0 +1,7 @@ +package com.example.otchallenge.base.api.di + +import javax.inject.Scope + +@Retention(AnnotationRetention.RUNTIME) +@Scope +annotation class FragmentScope \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/api/network/NetworkServicesFactory.kt b/component/base/src/main/java/com/example/otchallenge/base/api/network/NetworkServicesFactory.kt new file mode 100644 index 0000000..aea5450 --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/api/network/NetworkServicesFactory.kt @@ -0,0 +1,8 @@ +package com.example.otchallenge.base.api.network + +import kotlin.reflect.KClass + +interface NetworkServicesFactory { + + fun create(service: KClass): T +} \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/internal/di/BaseModule.kt b/component/base/src/main/java/com/example/otchallenge/base/internal/di/BaseModule.kt new file mode 100644 index 0000000..afc3b8d --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/internal/di/BaseModule.kt @@ -0,0 +1,13 @@ +package com.example.otchallenge.base.internal.di + +import com.example.otchallenge.base.internal.jsonparser.JsonParserModule +import com.example.otchallenge.base.internal.network.NetworkServicesModule +import dagger.Module + +@Module( + includes = [ + JsonParserModule::class, + NetworkServicesModule::class, + ] +) +interface BaseModule \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/internal/jsonparser/JsonParserModule.kt b/component/base/src/main/java/com/example/otchallenge/base/internal/jsonparser/JsonParserModule.kt new file mode 100644 index 0000000..7cc2925 --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/internal/jsonparser/JsonParserModule.kt @@ -0,0 +1,17 @@ +package com.example.otchallenge.base.internal.jsonparser + +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +internal object JsonParserModule { + + @Provides + @Singleton + fun provideMoshi(): Moshi { + return Moshi.Builder() + .build() + } +} \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/internal/network/NetworkServicesModule.kt b/component/base/src/main/java/com/example/otchallenge/base/internal/network/NetworkServicesModule.kt new file mode 100644 index 0000000..9cd97ff --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/internal/network/NetworkServicesModule.kt @@ -0,0 +1,37 @@ +package com.example.otchallenge.base.internal.network + +import com.example.otchallenge.base.api.network.NetworkServicesFactory +import com.example.otchallenge.base.internal.jsonparser.JsonParserModule +import com.squareup.moshi.Moshi +import dagger.Module +import dagger.Provides +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Singleton + +private const val BASE_URL = "https://api.nytimes.com/svc/books/v3/" + +@Module( + includes = [ + JsonParserModule::class, + OkHttpModule::class, + ] +) +internal object NetworkServicesModule { + + @Singleton + @Provides + fun provideNetworkServicesFactory( + moshi: Moshi, + okHttpClientProvider: Lazy<@JvmSuppressWildcards OkHttpClient> + ): NetworkServicesFactory { + val retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .callFactory { okHttpClientProvider.value.newCall(it) } + .build() + + return RetrofitNetworkServicesFactory(retrofit) + } +} \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/internal/network/OkHttpModule.kt b/component/base/src/main/java/com/example/otchallenge/base/internal/network/OkHttpModule.kt new file mode 100644 index 0000000..25d6ba5 --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/internal/network/OkHttpModule.kt @@ -0,0 +1,36 @@ +package com.example.otchallenge.base.internal.network + +import android.content.Context +import dagger.Module +import dagger.Provides +import okhttp3.Cache +import okhttp3.OkHttpClient +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +private const val NETWORK_CACHE_SIZE_BYTES: Long = 10 * 1024 * 1024 +private const val NETWORK_CACHE_FILE_NAME = "http-cache" +private const val CALL_TIMEOUT_SECONDS = 30L + +@Module +internal object OkHttpModule { + + @Provides + @Singleton + fun provideOkHttpClientLazy( + context: Context, + ): Lazy { + return lazy { + OkHttpClient.Builder() + .cache( + Cache( + File(context.cacheDir, NETWORK_CACHE_FILE_NAME), + NETWORK_CACHE_SIZE_BYTES + ) + ) + .callTimeout(CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } + } +} \ No newline at end of file diff --git a/component/base/src/main/java/com/example/otchallenge/base/internal/network/RetrofitNetworkServicesFactory.kt b/component/base/src/main/java/com/example/otchallenge/base/internal/network/RetrofitNetworkServicesFactory.kt new file mode 100644 index 0000000..09c6522 --- /dev/null +++ b/component/base/src/main/java/com/example/otchallenge/base/internal/network/RetrofitNetworkServicesFactory.kt @@ -0,0 +1,12 @@ +package com.example.otchallenge.base.internal.network + +import com.example.otchallenge.base.api.network.NetworkServicesFactory +import retrofit2.Retrofit +import kotlin.reflect.KClass + +internal class RetrofitNetworkServicesFactory(private val retrofit: Retrofit) : NetworkServicesFactory { + + override fun create(service: KClass): T { + return retrofit.create(service.java) + } +} \ No newline at end of file diff --git a/component/common/build.gradle b/component/common/build.gradle new file mode 100644 index 0000000..2d6fb94 --- /dev/null +++ b/component/common/build.gradle @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kapt) +} + +android { + namespace 'com.example.otchallenge.common' + + buildFeatures { + dataBinding = true + } +} + +dependencies { + api libs.androidx.recyclerview + api libs.timber + + implementation libs.glide + implementation libs.kotlinx.coroutines.core + + testImplementation libs.junit + testImplementation libs.kluent + testImplementation libs.kotlinx.coroutines.test +} \ No newline at end of file diff --git a/component/common/src/main/java/com/example/otchallenge/common/presenter/DataModelState.kt b/component/common/src/main/java/com/example/otchallenge/common/presenter/DataModelState.kt new file mode 100644 index 0000000..bc8d72f --- /dev/null +++ b/component/common/src/main/java/com/example/otchallenge/common/presenter/DataModelState.kt @@ -0,0 +1,41 @@ +package com.example.otchallenge.common.presenter + +import com.example.otchallenge.common.util.logNetworkError +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +@Suppress("UNCHECKED_CAST") +sealed class ViewModelState { + + val inProgress get() = this is InProgress + + val successData: T? get() = (this as? CanHaveData)?.data + + data class Success(override val data: T) : ViewModelState(), CanHaveData + + data class InProgress(override val data: T? = null) : ViewModelState(), CanHaveData + + data class Error(val throwable: Throwable) : ViewModelState() +} + +private interface CanHaveData { + val data: T? +} + +// TODO add unit tests +fun fetchDataAsStates( + newDataProvider: suspend () -> T, + actionDescriptionProvider: () -> String, + lastDataProvider: (() -> T?)? = null, +): Flow> = flow { + emit(ViewModelState.InProgress(lastDataProvider?.invoke())) + + emit( + try { + ViewModelState.Success(newDataProvider()) + } catch (e: Exception) { + logNetworkError(e, actionDescriptionProvider) + ViewModelState.Error(e) + } + ) +} \ No newline at end of file diff --git a/component/common/src/main/java/com/example/otchallenge/common/ui/BaseDiffCallback.kt b/component/common/src/main/java/com/example/otchallenge/common/ui/BaseDiffCallback.kt new file mode 100644 index 0000000..f641423 --- /dev/null +++ b/component/common/src/main/java/com/example/otchallenge/common/ui/BaseDiffCallback.kt @@ -0,0 +1,18 @@ +package com.example.otchallenge.common.ui + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.DiffUtil + +class BaseDiffCallback( + private val idExtractor: T.() -> Any? +) : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem.idExtractor() == newItem.idExtractor() + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem == newItem + } +} \ No newline at end of file diff --git a/component/common/src/main/java/com/example/otchallenge/common/ui/BindingAdapters.kt b/component/common/src/main/java/com/example/otchallenge/common/ui/BindingAdapters.kt new file mode 100644 index 0000000..d88ddf7 --- /dev/null +++ b/component/common/src/main/java/com/example/otchallenge/common/ui/BindingAdapters.kt @@ -0,0 +1,16 @@ +package com.example.otchallenge.common.ui + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.widget.ImageView +import androidx.databinding.BindingAdapter +import com.bumptech.glide.Glide + +@BindingAdapter("imageUrl") +fun ImageView.loadImage(imageUrl: String?) { + Glide.with(this) + .load(imageUrl) + .placeholder(ColorDrawable(Color.LTGRAY)) + .error(ColorDrawable(Color.LTGRAY)) + .into(this) +} \ No newline at end of file diff --git a/component/common/src/main/java/com/example/otchallenge/common/util/Logger.kt b/component/common/src/main/java/com/example/otchallenge/common/util/Logger.kt new file mode 100644 index 0000000..251cc25 --- /dev/null +++ b/component/common/src/main/java/com/example/otchallenge/common/util/Logger.kt @@ -0,0 +1,22 @@ +package com.example.otchallenge.common.util + +import timber.log.Timber +import java.io.IOException + +typealias Logger = Timber + +fun logNetworkError(throwable: Throwable, actionDescription: () -> String) { + when (throwable) { + is IOException -> { + Logger.w(throwable, "Connection problem") + } + + is Error -> { + Logger.e(throwable, "Fatal error while performing action: ${actionDescription()}") + } + + else -> { + Logger.e(throwable, "${actionDescription()} failed") + } + } +} diff --git a/component/common/src/test/java/com/example/otchallenge/common/ui/BaseDiffCallbackTest.kt b/component/common/src/test/java/com/example/otchallenge/common/ui/BaseDiffCallbackTest.kt new file mode 100644 index 0000000..bfd67b6 --- /dev/null +++ b/component/common/src/test/java/com/example/otchallenge/common/ui/BaseDiffCallbackTest.kt @@ -0,0 +1,63 @@ +package com.example.otchallenge.common.ui + +import org.amshove.kluent.`should be equal to` +import org.junit.Test + +class BaseDiffCallbackTest { + + private val tested = BaseDiffCallback { id } + + @Test + fun `given items have same ids, when areItemsTheSame called, return true`() { + // given + val item1 = TestDto("id", 1) + val item2 = TestDto("id", 2) + + // when + val result = tested.areItemsTheSame(item1, item2) + + // then + result `should be equal to` true + } + + @Test + fun `given items have different ids, when areItemsTheSame called, return false`() { + // given + val item1 = TestDto("id", 1) + val item2 = TestDto("id1", 1) + + // when + val result = tested.areItemsTheSame(item1, item2) + + // then + result `should be equal to` false + } + + @Test + fun `given items equal, when areContentsTheSame called, return true`() { + // given + val item1 = TestDto("id", 1) + val item2 = TestDto("id", 1) + + // when + val result = tested.areContentsTheSame(item1, item2) + + // then + result `should be equal to` true + } + + @Test + fun `given items not equal, when areContentsTheSame called, return false`() { + // given + val item1 = TestDto("id", 1) + val item2 = TestDto("id", 2) + + // when + val result = tested.areContentsTheSame(item1, item2) + + // then + result `should be equal to` false + } +} + +private data class TestDto(val id: String, val otherProperty: Int) diff --git a/feature/books-list/build.gradle b/feature/books-list/build.gradle new file mode 100644 index 0000000..7d400ff --- /dev/null +++ b/feature/books-list/build.gradle @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.kapt) +} + +android { + namespace 'com.example.otchallenge.bookslist' + + buildFeatures { + dataBinding = true + viewBinding = true + } +} + +dependencies { + implementation project(":component:base") + implementation project(":component:common") + + implementation libs.androidx.core.ktx + implementation libs.androidx.appcompat + implementation libs.material + implementation libs.androidx.constraintlayout + implementation libs.androidx.swiperefreshlayout + // dagger + implementation libs.dagger + kapt libs.dagger.compiler + + // TODO migrate project to KSP + //noinspection KaptUsageInsteadOfKsp + kapt libs.moshi.codegen + implementation libs.moshi.moshi + + implementation libs.retrofit + + testImplementation libs.junit + testImplementation libs.kluent + testImplementation libs.kotlinx.coroutines.test + testImplementation libs.mockito.kotlin + + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.espresso.core +} \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/api/BooksListComponentProvider.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/api/BooksListComponentProvider.kt new file mode 100644 index 0000000..f62deca --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/api/BooksListComponentProvider.kt @@ -0,0 +1,14 @@ +package com.example.otchallenge.bookslist.api + +import android.content.Context +import com.example.otchallenge.bookslist.internal.di.BooksListComponent + +interface BooksListComponentProvider { + + fun provideBooksListComponent(): BooksListComponent.Builder + + companion object { + internal fun provideFromContext(context: Context): BooksListComponent.Builder = + (context.applicationContext as BooksListComponentProvider).provideBooksListComponent() + } +} \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/api/BooksListFragment.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/api/BooksListFragment.kt new file mode 100644 index 0000000..584133c --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/api/BooksListFragment.kt @@ -0,0 +1,46 @@ +package com.example.otchallenge.bookslist.api + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.example.otchallenge.bookslist.databinding.BooksListFragmentBinding +import com.example.otchallenge.bookslist.internal.presenter.BooksListPresenter +import com.example.otchallenge.bookslist.internal.ui.BooksAdapter +import com.example.otchallenge.bookslist.internal.ui.BooksListViewImpl +import javax.inject.Inject + +class BooksListFragment : Fragment() { + + @Inject + internal lateinit var presenter: BooksListPresenter + + @Inject + internal lateinit var booksAdapter: BooksAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + BooksListComponentProvider.provideFromContext(requireContext()) + .lifecycleScope(lifecycleScope) + .build() + .inject(this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = BooksListFragmentBinding.inflate(inflater, container, false) + presenter.onViewCreated(BooksListViewImpl(binding, presenter, booksAdapter)) + return binding.root + } + + override fun onDestroyView() { + presenter.onViewDestroyed() + super.onDestroyView() + } +} \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/di/BooksListComponent.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/di/BooksListComponent.kt new file mode 100644 index 0000000..a41510e --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/di/BooksListComponent.kt @@ -0,0 +1,25 @@ +package com.example.otchallenge.bookslist.internal.di + +import com.example.otchallenge.base.api.di.FragmentScope +import com.example.otchallenge.bookslist.api.BooksListFragment +import dagger.BindsInstance +import dagger.Subcomponent +import kotlinx.coroutines.CoroutineScope + +@FragmentScope +@Subcomponent( + modules = [ + BooksListModule::class, + ] +) +interface BooksListComponent { + fun inject(fragment: BooksListFragment) + + @Subcomponent.Builder + interface Builder { + fun build(): BooksListComponent + + @BindsInstance + fun lifecycleScope(lifecycleScope: CoroutineScope): Builder + } +} diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/di/BooksListModule.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/di/BooksListModule.kt new file mode 100644 index 0000000..776119c --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/di/BooksListModule.kt @@ -0,0 +1,16 @@ +package com.example.otchallenge.bookslist.internal.di + +import com.example.otchallenge.base.api.di.FragmentScope +import com.example.otchallenge.base.api.network.NetworkServicesFactory +import com.example.otchallenge.bookslist.internal.repository.BooksListRepository +import dagger.Module +import dagger.Provides + +@Module +internal class BooksListModule { + @Provides + @FragmentScope + fun provideBooksListRepository(networkServicesFactory: NetworkServicesFactory): BooksListRepository { + return networkServicesFactory.create(BooksListRepository::class) + } +} diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/presenter/BooksListEvent.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/presenter/BooksListEvent.kt new file mode 100644 index 0000000..1e43921 --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/presenter/BooksListEvent.kt @@ -0,0 +1,5 @@ +package com.example.otchallenge.bookslist.internal.presenter + +internal sealed class BooksListEvent { + data object Refresh : BooksListEvent() +} \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/presenter/BooksListPresenter.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/presenter/BooksListPresenter.kt new file mode 100644 index 0000000..2a4931c --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/presenter/BooksListPresenter.kt @@ -0,0 +1,127 @@ +package com.example.otchallenge.bookslist.internal.presenter + +import androidx.annotation.StringRes +import com.example.otchallenge.base.api.di.FragmentScope +import com.example.otchallenge.bookslist.R +import com.example.otchallenge.bookslist.internal.repository.Book +import com.example.otchallenge.bookslist.internal.usecase.GetBooksListUseCase +import com.example.otchallenge.common.presenter.ViewModelState +import com.example.otchallenge.common.presenter.fetchDataAsStates +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal typealias BooksListState = ViewModelState> + +@FragmentScope +internal class BooksListPresenter( + private val lifecycleScope: CoroutineScope, + private val getBooksListUseCase: GetBooksListUseCase, + private val ioDispatcher: CoroutineDispatcher, +) { + + @Inject + constructor(lifecycleScope: CoroutineScope, getBooksListUseCase: GetBooksListUseCase) : + this(lifecycleScope, getBooksListUseCase, Dispatchers.IO) + + private var view: View? = null + + private val state: MutableStateFlow = MutableStateFlow(ViewModelState.InProgress()) + + private val loadTriggers = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_LATEST) + + init { + fetchListOnTrigger() + triggerLoad() + observeState() + } + + private fun triggerLoad() { + require(loadTriggers.tryEmit(Unit)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun fetchListOnTrigger() { + lifecycleScope.launch { + loadTriggers + .flatMapLatest { getBooksListState() } + .flowOn(ioDispatcher) + .collect { + state.value = it + } + } + } + + private fun getBooksListState(): Flow { + return fetchDataAsStates( + newDataProvider = { + getBooksListUseCase.execute() + }, + actionDescriptionProvider = { "Getting books list" }, + lastDataProvider = { state.value.successData } + ) + } + + private fun observeState() { + lifecycleScope.launch { + state.collect(::applyStateToView) + } + } + + fun onEvent(event: BooksListEvent) { + when (event) { + BooksListEvent.Refresh -> triggerLoad() + } + } + + fun onViewCreated(view: View) { + this.view = view + applyStateToView(state.value) + } + + fun onViewDestroyed() { + this.view = null + } + + private fun applyStateToView(state: BooksListState) { + val view = view ?: return + + view.setInProgress(state.inProgress) + + val booksList = state.successData + if (booksList.isNullOrEmpty()) { + view.hideBooksList() + val emptyViewMessage = when (state) { + is ViewModelState.Error -> R.string.books_list_empty_state_error + is ViewModelState.InProgress -> R.string.books_list_empty_state_loading + is ViewModelState.Success -> R.string.books_list_empty_state_no_results + } + view.showEmptyView(emptyViewMessage) + } else { + view.hideEmptyView() + view.showBooksList(booksList) + } + } + + interface View { + fun setInProgress(inProgress: Boolean) + + fun showBooksList(booksList: List) + + fun hideBooksList() + + fun showEmptyView(@StringRes messageId: Int) + + fun hideEmptyView() + } +} diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/repository/BooksListRepository.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/repository/BooksListRepository.kt new file mode 100644 index 0000000..4afd010 --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/repository/BooksListRepository.kt @@ -0,0 +1,9 @@ +package com.example.otchallenge.bookslist.internal.repository + +import retrofit2.http.GET + +internal interface BooksListRepository { + + @GET("lists/current/hardcover-fiction.json?api-key=KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB&offset=0") + suspend fun getBooksList(): BooksListResponse +} \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/repository/BooksListResponse.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/repository/BooksListResponse.kt new file mode 100644 index 0000000..b0e2e34 --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/repository/BooksListResponse.kt @@ -0,0 +1,22 @@ +package com.example.otchallenge.bookslist.internal.repository + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class BooksListResponse(val results: BooksListResults?) + +@JsonClass(generateAdapter = true) +internal data class BooksListResults(val books: List?) + +@JsonClass(generateAdapter = true) +internal data class Book( + val title: String?, + val description: String?, + @Json(name = "book_image") + val imageUrl: String?, + @Json(name = "primary_isbn10") + val isbn10: String?, + @Json(name = "primary_isbn13") + val isbn13: String?, +) \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/ui/BooksAdapter.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/ui/BooksAdapter.kt new file mode 100644 index 0000000..f88f56a --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/ui/BooksAdapter.kt @@ -0,0 +1,33 @@ +package com.example.otchallenge.bookslist.internal.ui + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.example.otchallenge.bookslist.databinding.BooksListItemBinding +import com.example.otchallenge.bookslist.internal.repository.Book +import com.example.otchallenge.common.ui.BaseDiffCallback +import javax.inject.Inject + +internal class BooksAdapter @Inject constructor() : + ListAdapter(BaseDiffCallback { isbn10 ?: isbn13 ?: this }) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserViewHolder { + val binding = BooksListItemBinding + .inflate(LayoutInflater.from(parent.context), parent, false) + + return UserViewHolder(binding) + } + + override fun onBindViewHolder(holder: UserViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + internal class UserViewHolder(private val binding: BooksListItemBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(book: Book) { + binding.book = book + binding.executePendingBindings() + } + } +} diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/ui/BooksListViewImpl.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/ui/BooksListViewImpl.kt new file mode 100644 index 0000000..36ef977 --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/ui/BooksListViewImpl.kt @@ -0,0 +1,43 @@ +package com.example.otchallenge.bookslist.internal.ui + +import androidx.core.view.isVisible +import com.example.otchallenge.bookslist.databinding.BooksListFragmentBinding +import com.example.otchallenge.bookslist.internal.presenter.BooksListEvent +import com.example.otchallenge.bookslist.internal.presenter.BooksListPresenter +import com.example.otchallenge.bookslist.internal.repository.Book + +internal class BooksListViewImpl( + private val binding: BooksListFragmentBinding, + private val presenter: BooksListPresenter, + private val adapter: BooksAdapter, +) : BooksListPresenter.View { + + init { + binding.swipeContainer.setOnRefreshListener { presenter.onEvent(BooksListEvent.Refresh) } + binding.booksList.adapter = adapter + } + + override fun setInProgress(inProgress: Boolean) { + binding.swipeContainer.isRefreshing = inProgress + } + + override fun showBooksList(booksList: List) { + binding.booksList.isVisible = true + adapter.submitList(booksList) + } + + override fun hideBooksList() { + binding.booksList.isVisible = false + } + + override fun showEmptyView(messageId: Int) { + binding.emptyView.apply { + isVisible = true + text = context.getString(messageId) + } + } + + override fun hideEmptyView() { + binding.emptyView.isVisible = false + } +} \ No newline at end of file diff --git a/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/usecase/GetBooksListUseCase.kt b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/usecase/GetBooksListUseCase.kt new file mode 100644 index 0000000..928ae7b --- /dev/null +++ b/feature/books-list/src/main/java/com/example/otchallenge/bookslist/internal/usecase/GetBooksListUseCase.kt @@ -0,0 +1,14 @@ +package com.example.otchallenge.bookslist.internal.usecase + +import com.example.otchallenge.base.api.di.FragmentScope +import com.example.otchallenge.bookslist.internal.repository.Book +import com.example.otchallenge.bookslist.internal.repository.BooksListRepository +import javax.inject.Inject + +@FragmentScope +internal class GetBooksListUseCase @Inject constructor(private val repository: BooksListRepository) { + + suspend fun execute(): List { + return repository.getBooksList().results?.books.orEmpty() + } +} \ No newline at end of file diff --git a/feature/books-list/src/main/res/layout/books_list_fragment.xml b/feature/books-list/src/main/res/layout/books_list_fragment.xml new file mode 100644 index 0000000..1e5a7a3 --- /dev/null +++ b/feature/books-list/src/main/res/layout/books_list_fragment.xml @@ -0,0 +1,31 @@ + + + + + + + + + + \ No newline at end of file diff --git a/feature/books-list/src/main/res/layout/books_list_item.xml b/feature/books-list/src/main/res/layout/books_list_item.xml new file mode 100644 index 0000000..3ed3cd8 --- /dev/null +++ b/feature/books-list/src/main/res/layout/books_list_item.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/feature/books-list/src/main/res/values/strings.xml b/feature/books-list/src/main/res/values/strings.xml new file mode 100644 index 0000000..2fc300d --- /dev/null +++ b/feature/books-list/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Photo of %1$s + Something went wrong :(\nPull to refresh the list content + Please wait… + The books list is empty + \ No newline at end of file diff --git a/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/presenter/BooksListPresenterTest.kt b/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/presenter/BooksListPresenterTest.kt new file mode 100644 index 0000000..9cf6183 --- /dev/null +++ b/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/presenter/BooksListPresenterTest.kt @@ -0,0 +1,123 @@ +package com.example.otchallenge.bookslist.internal.presenter + +import com.example.otchallenge.bookslist.R +import com.example.otchallenge.bookslist.internal.repository.TestBookFactory.createBook +import com.example.otchallenge.bookslist.internal.usecase.GetBooksListUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class BooksListPresenterTest { + + private val getBooksListUseCase: GetBooksListUseCase = mock() + private val view: BooksListPresenter.View = mock() + + @Test + fun `given getBooksListUseCase execution is in progress, when view created, then show empty view with progress state`() = + runTest(StandardTestDispatcher()) { + // given + val books = List(3) { createBook(it.toString()) } + getBooksListUseCase.stub { + onBlocking { execute() } doReturn books + } + val tested = createPresenter(backgroundScope) + + // when + tested.onViewCreated(view) + + // then + verify(view).setInProgress(true) + verify(view).showEmptyView(R.string.books_list_empty_state_loading) + verify(view).hideBooksList() + } + + @Test + fun `given getBooksListUseCase returns valid result, when view created, then show books list`() = + runTest(UnconfinedTestDispatcher()) { + // given + val books = List(3) { createBook(it.toString()) } + getBooksListUseCase.stub { + onBlocking { execute() } doReturn books + } + val tested = createPresenter(backgroundScope) + + // when + tested.onViewCreated(view) + + // then + verify(view).setInProgress(false) + verify(view).hideEmptyView() + verify(view).showBooksList(books) + } + + @Test + fun `given getBooksListUseCase throws exception, when view created, then show empty state with error`() = + runTest(UnconfinedTestDispatcher()) { + // given + getBooksListUseCase.stub { + onBlocking { execute() } doThrow RuntimeException() + } + val tested = createPresenter(backgroundScope) + + // when + tested.onViewCreated(view) + + // then + verify(view).setInProgress(false) + verify(view).showEmptyView(R.string.books_list_empty_state_error) + verify(view).hideBooksList() + } + + @Test + fun `given getBooksListUseCase returns empty result, when view created, then show no results view`() = + runTest(UnconfinedTestDispatcher()) { + // given + getBooksListUseCase.stub { + onBlocking { execute() } doReturn emptyList() + } + val tested = createPresenter(backgroundScope) + + // when + tested.onViewCreated(view) + + // then + verify(view).setInProgress(false) + verify(view).showEmptyView(R.string.books_list_empty_state_no_results) + verify(view).hideBooksList() + } + + @Test + fun `given getBooksListUseCase returns valid result, when refresh event received, then show books list with refreshed result`() = + runTest(UnconfinedTestDispatcher()) { + // given + val books1 = List(3) { createBook(it.toString()) } + val books2 = List(5) { createBook(it.toString()) } + getBooksListUseCase.stub { + onBlocking { execute() }.thenReturn(books1, books2) + } + val tested = createPresenter(backgroundScope) + tested.onViewCreated(view) + Mockito.reset(view) + + // when + tested.onEvent(BooksListEvent.Refresh) + + // then + verify(view).setInProgress(false) + verify(view).hideEmptyView() + verify(view).showBooksList(books2) + } + + private fun createPresenter(lifecycleScope: CoroutineScope) = + BooksListPresenter(lifecycleScope, getBooksListUseCase, UnconfinedTestDispatcher()) +} \ No newline at end of file diff --git a/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/repository/TestBookFactory.kt b/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/repository/TestBookFactory.kt new file mode 100644 index 0000000..806fd98 --- /dev/null +++ b/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/repository/TestBookFactory.kt @@ -0,0 +1,11 @@ +package com.example.otchallenge.bookslist.internal.repository + +internal object TestBookFactory { + fun createBook(id: String = "0") = Book( + title = "Title $id", + description = "Description $id", + isbn10 = "ISBN10 $id", + isbn13 = "ISBN13 $id", + imageUrl = null, + ) +} \ No newline at end of file diff --git a/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/usecase/GetBooksListUseCaseTest.kt b/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/usecase/GetBooksListUseCaseTest.kt new file mode 100644 index 0000000..03f2090 --- /dev/null +++ b/feature/books-list/src/test/java/com/example/otchallenge/bookslist/internal/usecase/GetBooksListUseCaseTest.kt @@ -0,0 +1,50 @@ +package com.example.otchallenge.bookslist.internal.usecase + +import com.example.otchallenge.bookslist.internal.repository.BooksListRepository +import com.example.otchallenge.bookslist.internal.repository.BooksListResponse +import com.example.otchallenge.bookslist.internal.repository.BooksListResults +import com.example.otchallenge.bookslist.internal.repository.TestBookFactory.createBook +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.`should be equal to` +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.stub + +class GetBooksListUseCaseTest { + private val repository: BooksListRepository = mock() + private val tested = GetBooksListUseCase(repository) + + @Test + fun `given repository returns response with not null books list, when executed, then return books list from repository`() = + runTest { + // given + val booksList = List(3) { + createBook(it.toString()) + } + repository.stub { + onBlocking { getBooksList() } doReturn BooksListResponse(BooksListResults(booksList)) + } + + // when + val result = tested.execute() + + // then + result `should be equal to` booksList + } + + @Test + fun `given repository returns response with null books list, when executed, then return empty list`() = + runTest { + // given + repository.stub { + onBlocking { getBooksList() } doReturn BooksListResponse(BooksListResults(null)) + } + + // when + val result = tested.execute() + + // then + result `should be equal to` emptyList() + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..132244e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,4 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4664302..6f449be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.4.2" -kotlin = "1.9.0" +kotlin = "1.9.21" +kotlinxCoroutines = "1.9.0" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.1.5" @@ -12,30 +13,39 @@ constraintlayout = "2.1.4" dagger = "2.51.1" retrofit = "2.11.0" glide = "4.16.0" -rx-android = "2.1.1" -rx-java = "2.2.21" -rx-kotlin = "2.4.0" +androidxNavigation = "2.8.4" +swiperefreshlayout = "1.1.0" +moshi = "1.15.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-recyclerview = { module="androidx.recyclerview:recyclerview", version = "1.3.2" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "androidxNavigation" } +androidx-navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidxNavigation" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } dagger = { group = "com.google.dagger", name = "dagger", version.ref = "dagger" } dagger-compiler = { group = "com.google.dagger", name = "dagger-compiler", version.ref = "dagger" } +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } +moshi-moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } -retrofit-rx-adapter = { group = "com.squareup.retrofit2", name = "adapter-rxjava2", version.ref = "retrofit" } +retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } -rx-java = {group = "io.reactivex.rxjava2", name="rxjava", version.ref = "rx-java"} -rx-android = {group = "io.reactivex.rxjava2", name="rxandroid", version.ref = "rx-android"} -rx-kotlin = {group = "io.reactivex.rxjava2", name="rxkotlin", version.ref = "rx-kotlin"} +androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } +timber = { module = "com.jakewharton.timber:timber", version = "5.0.1" } +kluent = { module = "org.amshove.kluent:kluent", version = "1.73"} +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref="kotlinxCoroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref="kotlinxCoroutines" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "5.4.0" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } diff --git a/settings.gradle b/settings.gradle index 72afdef..45f3dcd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,4 +20,19 @@ dependencyResolutionManagement { } rootProject.name = "AndroidOTChallenge" -include ':app' +includeAllGradleProjectsInCurrentDirectoryTree() + +/* Utility methods */ + +private List includeAllGradleProjectsInCurrentDirectoryTree() { + getAllBuildGradleFilesInCurrectDirectoryTree() + .collect { relativePath(it.parent).replace(File.separator, ':') } + .each { include(it) } +} + +private ConfigurableFileTree getAllBuildGradleFilesInCurrectDirectoryTree() { + fileTree('.') { + include '**/build.gradle' + exclude 'build.gradle' // Exclude the root build file. + } +}