<file_sep>package com.gutenberg.bookowl.view.genre

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.gutenberg.bookowl.R
import com.gutenberg.bookowl.data.models.Genre
import kotlinx.android.synthetic.main.item_genre.view.*

class GenreListAdapter(
    private val genreList: List<Genre>,
    val onGenreSelected: (genre: Genre) -> Unit
) : RecyclerView.Adapter<GenreListAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
        LayoutInflater.from(parent.context)
            .inflate(R.layout.item_genre, parent, false)
    )


    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(genreList[position])
    }

    override fun getItemCount(): Int {
        return genreList.size
    }


    //View holder for Genre item
    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView),
        View.OnClickListener {

        private lateinit var genre: Genre

        init {
            itemView.container_genre.setOnClickListener(this)
        }

        fun bind(genre: Genre) {
            this.genre = genre
            itemView.apply {
                tv_genre_title.text = genre.title
                imv_genre_icon.setImageResource(genre.icon)
            }
        }

        override fun onClick(v: View?) {
            onGenreSelected(genre)
        }
    }
}<file_sep>package com.gutenberg.bookowl.data.models

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.gutenberg.bookowl.application.*

@JsonIgnoreProperties(ignoreUnknown = true)
data class BookFormat(

    @JsonProperty(MIME_IMAGE)
    var bookCoverUrl: String? = "",

    @JsonProperty(MIME_PDF)
    var pdfUrl: String? = "",

    @JsonProperty(MIME_TEXT_PLAIN_ASCII)
    var plainTextUrl: String? = "",

    @JsonProperty(MIME_TEXT_PLAIN_UTF_8)
    var plainText_UTF_8_Url: String? = "",

    @JsonProperty(MIME_TEXT_HTML_ASCII)
    var htmlTextUrl: String? = null,

    @JsonProperty(MIME_TEXT_HTML_UTF_8)
    var htmlText_UTF_8_Url: String? = null

)<file_sep>package com.gutenberg.bookowl.data

import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.gutenberg.bookowl.BuildConfig
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.jackson.JacksonConverterFactory
import java.util.concurrent.TimeUnit

object ApiClient {

    private val BASE_URL = "http://skunkworks.ignitesol.com:8000/"

    /**this is used to log the API calls made by the app in debug build*/
    private val loggingInterceptor: HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG) {
            HttpLoggingInterceptor.Level.BODY
        } else {
            HttpLoggingInterceptor.Level.NONE
        }
    }

    private val okHttpClient: OkHttpClient = OkHttpClient.Builder().run {
        addInterceptor(loggingInterceptor)
        readTimeout(30, TimeUnit.SECONDS)
        writeTimeout(30, TimeUnit.SECONDS)
        this.build()
    }

    /**here we set the configuration for serialization/deserialization via ObjctMapper
     * add any converters like Jackson, Gson, etc. and other configurations*/
    private val retrofitBuilder: Retrofit.Builder = Retrofit.Builder().apply {
        //configuring object mapper
        val objectMapper = ObjectMapper().apply {
            // don't fail on unknown properties
            configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
            configure(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES, false)
        }
        addConverterFactory(JacksonConverterFactory.create(objectMapper))
        addCallAdapterFactory(RxJava2CallAdapterFactory.create())
        client(okHttpClient)
    }

    //building the retrofit client with baseUrl
    private val retrofitClient: Retrofit = retrofitBuilder
        .baseUrl(BASE_URL)
        .build()


    //building the retrofit client without baseUrl
    private val baselessRetrofitClient: Retrofit by lazy {
        retrofitBuilder
            .build()
    }

    fun <T> createApiService(serviceClass: Class<T>): T {
        return retrofitClient.create(serviceClass)
    }

    fun <T> createBaselessService(serviceClass: Class<T>): T {
        return baselessRetrofitClient.create(serviceClass)
    }
}<file_sep>package com.gutenberg.bookowl.data.models

import com.fasterxml.jackson.annotation.JsonProperty

data class Book(

    @JsonProperty("id")
    var id: Long = 0L,

    @JsonProperty("title")
    var title: String = "",

    @JsonProperty("authors")
    var authors: List<Author> = emptyList(),

    @JsonProperty("formats")
    var bookFormat: BookFormat = BookFormat()
)<file_sep>package com.gutenberg.bookowl.data.models

import androidx.annotation.DrawableRes

data class Genre(

    @DrawableRes
    val icon: Int,

    val title: String
)<file_sep>package com.gutenberg.bookowl.data.books

import com.gutenberg.bookowl.application.MIME_IMAGE
import com.gutenberg.bookowl.data.ApiClient
import com.gutenberg.bookowl.data.models.BookSearchResult
import io.reactivex.Single

class BooksApiManager {

    private val booksApiService = ApiClient.createApiService(BooksApiService::class.java)
    private val booksResultPageApiService =
        ApiClient.createBaselessService(BooksResultPageApiService::class.java)

    fun getBooks(genreTitle: String): Single<BookSearchResult> {
        return booksApiService.getBooks(genreTitle, MIME_IMAGE)
    }

    fun getBooks(genreTitle: String, searchQuery: String): Single<BookSearchResult> {
        return booksApiService.getBooks(genreTitle, searchQuery.toLowerCase(), MIME_IMAGE)
    }

    fun getBooksFromPage(pageUrl: String): Single<BookSearchResult> {
        return booksResultPageApiService.getBooksFromPage(pageUrl)
    }
}<file_sep>package com.gutenberg.bookowl.view.books

import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.View
import android.widget.SearchView
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.gutenberg.bookowl.R
import com.gutenberg.bookowl.application.KEY_GENRE
import com.gutenberg.bookowl.application.extensions.*
import com.gutenberg.bookowl.application.utils.GridSpacingItemDecoration
import com.gutenberg.bookowl.data.models.Book
import com.gutenberg.bookowl.view.BaseActivity
import kotlinx.android.synthetic.main.activity_books.*
import kotlin.math.roundToInt


class BooksActivity : BaseActivity(), View.OnClickListener {

    private var areBooksScrolledToLast: Boolean = false
    private lateinit var booksScrollListener: RecyclerView.OnScrollListener
    private val booksAdapter = BooksAdapter(this::onBookClicked)
    private val viewModel by lazy {
        val genreTitle = intent.getStringExtra(KEY_GENRE)
        configureViewModel {
            BooksViewModel(genreTitle ?: "")
        }
    }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_books)
        btn_back.setOnClickListener(this)

        //setting genre and books adapter
        tv_genre_title.text = viewModel.genreTitle.capitalize()

        //setting up books listing
        initBooksListing()
        observeBooksList()
        initSearch()

        //fetching initial books list
        viewModel.getBooks()
    }

    override fun onDestroy() {
        rv_books.removeOnScrollListener(booksScrollListener)
        super.onDestroy()
    }

    /**
     * responding to changes in books list query
     * */
    private fun observeBooksList() {
        viewModel.booksLiveResult.observe(this, Observer {
            it.parseResult({
                //loading
                toggleProgress(show = true)
            }, { booksList ->
                //content
                toggleProgress(show = false)
                booksAdapter.swapData(booksList)
            }, { errorThrowable ->
                //error
                showError(errorThrowable)
                logError(
                    tag = "BooksError",
                    message = "Error getting books",
                    throwable = errorThrowable
                )
            })
        })
    }


    /**
     * Configures recyclerView for books
     * */
    private fun initBooksListing() {

        //setting up grid span count based on screen size
        val display = windowManager.defaultDisplay
        val outMetrics = DisplayMetrics()
        display.getMetrics(outMetrics)

        val density = resources.displayMetrics.density
        val screenDpWidth = outMetrics.widthPixels / density
        val columns = (screenDpWidth / 114).roundToInt() - 1
        val layoutManager = GridLayoutManager(this, columns)
        rv_books.layoutManager = layoutManager

        //adding equal spacing item decoration
        rv_books.addItemDecoration(
            GridSpacingItemDecoration(
                columns,
                resources.getDimensionPixelSize(R.dimen.width_book_cover)
            )
        )


        //setting up scroll listener
        booksScrollListener = object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {

                //calculating if the user has scrolled to last items
                val firstVisibleItem = layoutManager.findFirstVisibleItemPosition()
                val visibleItemCount = layoutManager.childCount
                val totalItemCount = layoutManager.itemCount
                areBooksScrolledToLast = firstVisibleItem + visibleItemCount >= totalItemCount

                //loading more books
                if (viewModel.canLoadMoreBooks() && areBooksScrolledToLast) {
                    viewModel.getBooksFromNextPage()
                }
            }
        }

        rv_books.apply {
            adapter = booksAdapter
            addOnScrollListener(booksScrollListener)
        }
    }


    /**
     * Handle state for views related to error
     * */
    private fun showError(throwable: Throwable) {
        toggleProgress(show = false)
        imv_error.visibleOrGone(booksAdapter.itemCount == 0)

        //showing error image
        if (booksAdapter.itemCount == 0) {
            imv_error.setImageResource(
                if (throwable.causedByInternetConnectionIssue())
                    R.drawable.art_no_internet
                else
                    R.drawable.art_something_wrong
            )
        }

        //showing toast
        toast(
            if (throwable.causedByInternetConnectionIssue())
                R.string.error_no_internet
            else
                R.string.error_unknown
        )
    }

    /**
     * Handle state for views related to loading
     * */
    private fun toggleProgress(show: Boolean) {
        pb_loading.visibleOrGone(show && areBooksScrolledToLast.not())
        pb_load_more_books.visibleOrGone(show && areBooksScrolledToLast)
    }

    /**
     * Start listening to user search
     * */
    private fun initSearch() {
        sv_search_books.setOnQueryTextListener(object : SearchView.OnQueryTextListener {

            override fun onQueryTextSubmit(query: String?): Boolean {
                viewModel.searchQuerySubject.onComplete()
                return true
            }

            override fun onQueryTextChange(newText: String?): Boolean {
                viewModel.searchQuerySubject.onNext(newText ?: "")
                return true
            }
        })
        viewModel.listenToSearch()
    }


    //book click listener
    private fun onBookClicked(book: Book) {
        book.bookFormat.apply {
            val allFormats = arrayOf(
                htmlText_UTF_8_Url, htmlTextUrl, pdfUrl,
                plainText_UTF_8_Url, plainTextUrl
            )

            var bookContainsValidFormat = false
            for (formatUrl in allFormats) {
                if (formatUrl.isNullOrEmpty().not() && formatUrl?.isZipFile() == false) {
                    formatUrl.openLink(this@BooksActivity)
                    bookContainsValidFormat = true
                    break
                }
            }

            //showing error for no viewable version available
            if (bookContainsValidFormat.not()) {
                showAlertDialog(
                    R.string.error_title,
                    R.string.error_invalid_book_format,
                    R.string.action_okay
                )
            }
        }
    }

    //click listener
    override fun onClick(view: View?) {
        when (view?.id) {
            R.id.btn_back -> onBackPressed()
        }
    }

    companion object {
        fun getIntent(context: Context, genreTitle: String): Intent {
            return Intent(context, BooksActivity::class.java).apply {
                putExtra(KEY_GENRE, genreTitle)
            }
        }
    }
}
<file_sep>package com.gutenberg.bookowl.view

import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity


abstract class BaseActivity : AppCompatActivity() {

    private var baseAlertDialog: AlertDialog? = null

    fun toast(@StringRes message: Int) {
        createToast(getString(message)).show()
    }

    fun toast(message: String) {
        createToast(message).show()
    }

    fun createToast(message: String): Toast {
        return Toast.makeText(this, message, Toast.LENGTH_LONG)
    }

    //method to show error dialog
    protected fun showAlertDialog(
        @StringRes title: Int,
        @StringRes message: Int,
        @StringRes positiveLabel: Int,
        @StringRes negativeLabel: Int? = null,
        onPositiveAction: (() -> Unit)? = null,
        onNegativeAction: (() -> Unit)? = null
    ) {
        if (baseAlertDialog == null) {
            val dialogBuilder = AlertDialog.Builder(this)
                .setTitle(title)
                .setMessage(message)
                .setPositiveButton(positiveLabel) { dialog, _ ->
                    onPositiveAction?.invoke() ?: dialog.dismiss()
                }
            negativeLabel?.let {
                dialogBuilder.setNegativeButton(it) { dialog, _ ->
                    onNegativeAction?.invoke() ?: dialog.dismiss()
                }
            }
            baseAlertDialog = dialogBuilder.create()
        } else {
            baseAlertDialog?.apply {
                dismiss()
                setTitle(title)
                setMessage(getString(message))

                //setting positive button
                setButton(
                    AlertDialog.BUTTON_POSITIVE,
                    getString(positiveLabel)
                ) { _, _ ->
                    onPositiveAction?.invoke()
                }

                //setting negative button
                val negativeLblString = negativeLabel?.let {
                    getString(it)
                } ?: ""

                setButton(
                    AlertDialog.BUTTON_NEGATIVE,
                    negativeLblString
                ) { _, _ ->
                    onNegativeAction?.invoke()
                }
            }
        }
        baseAlertDialog?.show()
    }
}<file_sep>package com.gutenberg.bookowl.application.extensions

import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider

@Suppress("UNCHECKED_CAST")
class BaseVMFactory<T>(val block: () -> T) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return block() as T
    }
}

inline fun <reified T : ViewModel> FragmentActivity.configureViewModel(noinline block: (() -> T)? = null): T {
    return if (block == null) {
        ViewModelProvider(this).get(T::class.java)
    } else {
        ViewModelProvider(this, BaseVMFactory(block)).get(T::class.java)
    }
}

inline fun <reified T : ViewModel> Fragment.configureViewModel(noinline block: (() -> T)? = null): T {
    return if (block == null) {
        ViewModelProvider(this).get(T::class.java)
    } else {
        ViewModelProvider(this, BaseVMFactory(block)).get(T::class.java)
    }
}<file_sep>package com.gutenberg.bookowl.application

const val KEY_GENRE = "genre"

//mimetypes
const val MIME_IMAGE = "image/jpeg"
const val MIME_PDF = "application/pdf"
const val MIME_TEXT_PLAIN_ASCII = "text/plain; charset=us-ascii"
const val MIME_TEXT_PLAIN_UTF_8 = "text/plain; charset=utf-8"
const val MIME_TEXT_HTML_ASCII = "text/html; charset=us-ascii"
const val MIME_TEXT_HTML_UTF_8 = "text/html; charset=utf-8"<file_sep>package com.gutenberg.bookowl.view.genre

import android.content.res.TypedArray
import androidx.lifecycle.ViewModel
import com.gutenberg.bookowl.data.models.Genre

class GenreViewModel : ViewModel() {

    /**
     * In an ideal scenario "Genre list" is created
     * from an API response
     * */
    fun getGenreList(icons: TypedArray, titles: Array<String>): List<Genre> {
        val genreList = ArrayList<Genre>(titles.size)

        //creating custom list
        titles.forEachIndexed { index, title ->
            genreList.add(
                Genre(
                    icons.getResourceId(index, 0),
                    title
                )
            )
        }
        icons.recycle()
        return genreList
    }
}<file_sep>package com.gutenberg.bookowl.application.extensions

import android.util.Log
import com.gutenberg.bookowl.BuildConfig

fun Any.logError(
    tag: String? = null,
    message: String? = null,
    throwable: Throwable? = null,
    isOnlyForDebug: Boolean? = null
) {
    log(Log.ERROR, tag, message, throwable, isOnlyForDebug)
}

fun Any.logInfo(
    tag: String? = null,
    message: String? = null,
    throwable: Throwable? = null,
    isOnlyForDebug: Boolean? = null
) {
    log(Log.INFO, tag, message, throwable, isOnlyForDebug)
}

fun Any.logDebug(
    tag: String? = null,
    message: String? = null,
    throwable: Throwable? = null,
    isOnlyForDebug: Boolean? = null
) {
    log(Log.DEBUG, tag, message, throwable, isOnlyForDebug)
}

fun Any.logVerbose(
    tag: String? = null,
    message: String? = null,
    throwable: Throwable? = null,
    isOnlyForDebug: Boolean? = null
) {
    log(Log.VERBOSE, tag, message, throwable, isOnlyForDebug)
}

fun Any.logWarning(
    tag: String? = null,
    message: String? = null,
    throwable: Throwable? = null,
    isOnlyForDebug: Boolean? = null
) {
    log(Log.WARN, tag, message, throwable, isOnlyForDebug)
}

private fun Any.log(
    type: Int,
    tag: String?,
    message: String?,
    throwable: Throwable?,
    isOnlyForDebug: Boolean?
) {
    val logTag: String = APPLICATION_TAG + (tag ?: DEFAULT_TAG)
    val shouldLog: Boolean = isOnlyForDebug ?: BuildConfig.DEBUG
    if (shouldLog) {
        when (type) {
            Log.DEBUG -> Log.d(logTag, message, throwable)
            Log.ERROR -> Log.e(logTag, message, throwable)
            Log.INFO -> Log.i(logTag, message, throwable)
            Log.WARN -> Log.w(logTag, message, throwable)
            Log.VERBOSE -> Log.v(logTag, message, throwable)
        }
    }
}

// if tag is not specified it will take the name of the class from which the log has been printed
private val Any.DEFAULT_TAG: String get() = this::class.java.simpleName

private const val APPLICATION_TAG = "iDev/"<file_sep>package com.gutenberg.bookowl.view.genre

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.gutenberg.bookowl.R
import com.gutenberg.bookowl.application.extensions.configureViewModel
import com.gutenberg.bookowl.data.models.Genre
import com.gutenberg.bookowl.view.books.BooksActivity
import kotlinx.android.synthetic.main.activity_genre.*

class GenreActivity : AppCompatActivity() {

    private val viewModel by lazy {
        configureViewModel<GenreViewModel>()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_genre)
        initGenreList()
    }

    private fun initGenreList() {
        resources.apply {
            val genreList = viewModel.getGenreList(
                obtainTypedArray(R.array.genre_icons),
                getStringArray(R.array.genre_titles)
            )
            rv_genre_listing.adapter =
                GenreListAdapter(genreList, this@GenreActivity::onGenreSelected)
        }
    }

    private fun onGenreSelected(genre: Genre) {
        startActivity(BooksActivity.getIntent(this, genre.title))
    }
}
<file_sep>package com.gutenberg.bookowl.application.extensions

import android.view.View

fun View.gone() {
    this.visibility = View.GONE
}

fun View.visible() {
    this.visibility = View.VISIBLE
}

fun View.invisible() {
    this.visibility = View.INVISIBLE
}

fun View.isVisible(): Boolean {
    return this.visibility == View.VISIBLE
}

fun View.isGone(): Boolean {
    return this.visibility == View.GONE
}

fun View.isInvisible(): Boolean {
    return this.visibility == View.INVISIBLE
}

fun View.visibleOrGone(isViewVisible: Boolean) {
    if (isViewVisible) {
        this.visible()
    } else {
        this.gone()
    }
}

fun View.visibleOrInvisible(visible: Boolean) {
    if (visible) {
        this.visible()
    } else {
        this.invisible()
    }
}

<file_sep>package com.gutenberg.bookowl.application

/**
 * This class in an implementation of
 * <Loading Content Error> Design Pattern
 * */
sealed class Result<T> {

    class Progress<T> : Result<T>()
    data class Success<T>(var data: T) : Result<T>()
    data class Error<T>(var error: Throwable) : Result<T>()

    companion object {
        fun <T> loading(): Result<T> = Progress()
        fun <T> success(data: T): Result<T> = Success(data)
        fun <T> error(error: Throwable): Result<T> = Error(error)
    }

    fun isLoading() = this is Progress
    fun isError() = this is Error
    fun isSuccess() = this is Success

    fun getResult(): T? {
        return if (this is Result.Success) {
            return this.data

        } else null
    }

    fun parseResult(
        progress: () -> Unit,
        success: (data: T) -> Unit,
        error: (error: Throwable) -> Unit
    ) {
        when (this) {
            // loading
            is Progress -> {
                progress()
            }

            //success
            is Success -> {
                success(this.data)
            }

            //error
            is Error -> {
                error(this.error)
            }
        }
    }
}<file_sep>package com.gutenberg.bookowl.application.extensions

import java.net.ConnectException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException

fun <T : Throwable> T.causedByInternetConnectionIssue(): Boolean {
    return (this is UnknownHostException || this is SocketTimeoutException
            || this is ConnectException || this is SocketException
            || (this.cause != null && this.cause!!.causedByInternetConnectionIssue()))
}<file_sep>package com.gutenberg.bookowl.application.utils

import android.widget.ImageView
import com.gutenberg.bookowl.R
import com.squareup.picasso.Picasso


class ImageLoader {

    companion object {

        const val placeHolderId = R.drawable.shape_rect_grey_cr_8

        //fixme:: add application context instad of viewContext
        fun loadUrl(imageView: ImageView, url: String?) {
            if (!url.isNullOrEmpty()) {
                Picasso.get()
                    .load(url)
                    .placeholder(placeHolderId)
                    .into(imageView)
            } else {
                Picasso.get()
                    .load(placeHolderId)
                    .into(imageView)
            }
        }

    }
}<file_sep>package com.gutenberg.bookowl.application.extensions

import io.reactivex.Observable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers

fun <T> Observable<T>.subscribeOnBackObserverOnMain(): Observable<T> {
    return this.subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
}

fun <T> Single<T>.subscribeOnBackObserverOnMain(): Single<T> {
    return this.subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
}


fun Disposable.addTo(disposable: CompositeDisposable) {
    disposable.add(this)
}
<file_sep>package com.gutenberg.bookowl.data.books

import com.gutenberg.bookowl.data.models.BookSearchResult
import io.reactivex.Single
import retrofit2.http.GET
import retrofit2.http.Url

interface BooksResultPageApiService {
    @GET
    fun getBooksFromPage(
        @Url pagePath: String
    ): Single<BookSearchResult>
}<file_sep>package com.gutenberg.bookowl.data.models

import com.fasterxml.jackson.annotation.JsonProperty

data class Author(

    @JsonProperty("name")
    var name: String = ""
)<file_sep>package com.gutenberg.bookowl.application

import androidx.lifecycle.MutableLiveData

class LiveResult<T> : MutableLiveData<Result<T>>() {

    fun loading() {
        value = Result.loading()
    }

    /**this used when setting value from background thread*/
    fun postLoading() {
        postValue(Result.loading())
    }

    fun success(data: T) {
        value = Result.success(data)
    }

    /**this used when setting value from background thread*/
    fun postSucess(data: T) {
        postValue(Result.success(data))
    }

    fun error(error: Throwable) {
        value = Result.error(error)
    }

    fun isLoading() = this.value?.isLoading() ?: false
    fun isSuccess() = this.value?.isSuccess() ?: false
    fun isError() = this.value?.isError() ?: false
    fun getResult() = this.value?.getResult()

}<file_sep>package com.gutenberg.bookowl.data.books

import com.gutenberg.bookowl.data.models.BookSearchResult
import io.reactivex.Single
import retrofit2.http.GET
import retrofit2.http.Query

interface BooksApiService {

    @GET("books/")
    fun getBooks(
        @Query("topic") genre: String,
        @Query("mime_type") mimeType: String
    ): Single<BookSearchResult>

    @GET("books/")
    fun getBooks(
        @Query("topic") genre: String,
        @Query("search") searchString: String,
        @Query("mime_type") mimeType: String
    ): Single<BookSearchResult>
}<file_sep>include ':app'
rootProject.name='BookOwl'
<file_sep>package com.gutenberg.bookowl.view.books

import android.view.LayoutInflater
import android.view.View
import android.view.View.OnClickListener
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.gutenberg.bookowl.R
import com.gutenberg.bookowl.application.utils.ImageLoader
import com.gutenberg.bookowl.data.models.Book
import kotlinx.android.synthetic.main.item_book.view.*

class BooksAdapter(private val onBookClicked: (book: Book) -> Unit) :
    ListAdapter<Book, BooksAdapter.BookViewHolder>(BookDiffCallback()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = BookViewHolder(
        LayoutInflater.from(parent.context)
            .inflate(R.layout.item_book, parent, false)
    )

    override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    fun swapData(data: List<Book>) {
        submitList(data.toMutableList())
    }

    /**
     * view holder for books item
     * */
    inner class BookViewHolder(itemView: View) :
        RecyclerView.ViewHolder(itemView), OnClickListener {

        init {
            itemView.setOnClickListener(this)
            itemView.imv_book_cover.clipToOutline = true
        }

        override fun onClick(v: View?) {
            if (adapterPosition == RecyclerView.NO_POSITION) return
            val selectedBook = getItem(adapterPosition)
            onBookClicked(selectedBook)
        }

        fun bind(book: Book) = with(itemView) {
            tv_book_title.text = book.title

            //setting author name
            tv_book_author.text = if (book.authors.isNotEmpty()) {
                book.authors[0].name
            } else {
                ""
            }

            //setting book cover
            ImageLoader.loadUrl(imv_book_cover, book.bookFormat.bookCoverUrl)
        }
    }

    interface Interaction {}

    /**
     * This class propagates changes to the recycler view
     * for a given difference between old and new data
     * */
    private class BookDiffCallback : DiffUtil.ItemCallback<Book>() {

        override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
            return oldItem == newItem
        }
    }
}<file_sep>package com.gutenberg.bookowl.view.books

import androidx.lifecycle.ViewModel
import com.gutenberg.bookowl.application.LiveResult
import com.gutenberg.bookowl.application.extensions.addTo
import com.gutenberg.bookowl.application.extensions.subscribeOnBackObserverOnMain
import com.gutenberg.bookowl.data.books.BooksApiManager
import com.gutenberg.bookowl.data.models.Book
import com.gutenberg.bookowl.data.models.BookSearchResult
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.PublishSubject
import java.util.concurrent.TimeUnit

/**
 * Single Source of truth: API endpoint
 * */
class BooksViewModel(val genreTitle: String) : ViewModel() {

    //connects to the api endpoint
    private val booksApiManager = BooksApiManager()
    //reactive stream for books list
    val booksLiveResult = LiveResult<MutableList<Book>>()
    //search string reactive stream
    val searchQuerySubject: PublishSubject<String> by lazy {
        PublishSubject.create<String>()
    }
    //stores the latest result for books
    private var latestBookSearchResult: BookSearchResult? = null
    private val disposable = CompositeDisposable()

    fun getBooks() {
        booksApiManager.getBooks(genreTitle)
            .subscribeOnBackObserverOnMain()
            .doOnSubscribe { booksLiveResult.loading() }
            .subscribe({ bookSearchResult ->
                //success
                latestBookSearchResult = bookSearchResult
                booksLiveResult.success(bookSearchResult.booksList.toMutableList())
            }, { throwable ->
                //error
                booksLiveResult.error(throwable)
            })
            .addTo(disposable)
    }

    /**
     * Fetches next page from the current result of books
     * */
    fun getBooksFromNextPage() {
        latestBookSearchResult?.nextPageUrl?.let { nextPageUrl ->
            booksApiManager.getBooksFromPage(nextPageUrl)
                .subscribeOnBackObserverOnMain()
                .doOnSubscribe { booksLiveResult.loading() }
                .subscribe({ nextPageResult ->
                    //success
                    //appending books from next page result into current books list
                    val booksList = mutableListOf<Book>().apply {
                        addAll(latestBookSearchResult?.booksList ?: emptyList())
                        addAll(nextPageResult.booksList)
                    }
                    booksLiveResult.success(booksList)

                    latestBookSearchResult = nextPageResult
                }, { throwable ->
                    //error
                    booksLiveResult.error(throwable)
                })
                .addTo(disposable)
        }
    }

    /**
     * This method listens for any search queries from the user
     * */
    fun listenToSearch() {
        searchQuerySubject.debounce(400, TimeUnit.MILLISECONDS)
            .distinctUntilChanged()
            .doOnEach { booksLiveResult.postLoading() }
            .switchMap { searchQuery ->

                //defining the observable to return
                val bookQueryObservable: Single<BookSearchResult> =
                    if (searchQuery.isEmpty()) {
                        booksApiManager.getBooks(genreTitle)
                    } else {
                        booksApiManager.getBooks(genreTitle, searchQuery)
                    }

                //returning the observable
                return@switchMap bookQueryObservable
                    .toObservable()
                    .subscribeOn(Schedulers.io())
            }
            .subscribeOnBackObserverOnMain()
            .subscribe({ bookSearchResult ->
                //success
                latestBookSearchResult = bookSearchResult
                booksLiveResult.success(bookSearchResult.booksList.toMutableList())
            }, { throwable ->
                //error
                booksLiveResult.error(throwable)
            })
            .addTo(disposable)
    }


    //checks for availability of next page
    fun canLoadMoreBooks() = latestBookSearchResult?.nextPageUrl != null &&
            booksLiveResult.isLoading().not()

    override fun onCleared() {
        disposable.clear()
        super.onCleared()
    }
}<file_sep>package com.gutenberg.bookowl.data.models

import com.fasterxml.jackson.annotation.JsonProperty

data class BookSearchResult(

    @JsonProperty("count")
    var totalBooks: Int = 0,

    @JsonProperty("next")
    var nextPageUrl: String? = null,

    @JsonProperty("previous")
    var previousPageUrl: String? = null,

    @JsonProperty("results")
    var booksList: List<Book> = emptyList()
)<file_sep>package com.gutenberg.bookowl.application.extensions

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.URLUtil

fun String.openLink(context: Context) {
    if (URLUtil.isValidUrl(this)) {
        context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(this)))
    }
}

fun String.isZipFile(): Boolean {
    return this.endsWith(".zip")
}