loading...

Building a Pokedex app with Kotlin, KVision and Redux (part 1)

rjaros profile image Robert Jaros Updated on ・5 min read

Hello all :)

In this article I will show you how to build a web application with Kotlin, KVision and Redux. You could know Redux quite well, but you certainly do not know how easy it can be used in a web application created in Kotlin.

KVision is an open source web framework created for Kotlin/JS. It allows developers to build modern web applications with the Kotlin language. You can find my short introduction to KVision in this article.

Redux is a popular predictable state container for JavaScript apps. Support for Redux, in the form of kvision-redux module, was added to KVision just a week ago.

In the first part of the article we will design and implement the Redux model for our application. We will implement the state class, the actions classes and the reducer function. In the second part we will create the user interface.

You can find the complete application and all the sources in the kvision-examples GitHub repository.

The specification

We want to create a simple application with the following features:

  • It shows a list of Pokémons with a name and a picture.
  • It uses PokéAPI RESTful services.
  • It allows to search for a Pokémon by name.
  • It displays the results in the responsive grid with a pagination.

This application is loosely based on this project, from the official React examples.

The template

To start we will clone the template project from the kvision-examples GitHub repository. To work with Redux we need to add kvision-redux module. These are the dependencies in our build.gradle file:

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib-js:${kotlinVersion}"
    compile "pl.treksoft:kvision:${kvisionVersion}"
    compile "pl.treksoft:kvision-bootstrap:${kvisionVersion}"
    compile "pl.treksoft:kvision-i18n:${kvisionVersion}"
    compile "pl.treksoft:kvision-redux:${kvisionVersion}"
}

The state

The state of the application will be stored in the Pokedex class. It contains the information about the downloading process (which can possibly end with an error), the list of all downloaded Pokémon objects, a list of Pokémons visible on the current page, the current search string, the current page number and the number of available pages. KVision requires the state object to be serializable with kotlinx.serialization library, so we need a @Serializable annotations on the Pokedex and the Pokemon classes.

@Serializable
data class Pokemon(val name: String, val url: String)

@Serializable
data class Pokedex(
    val downloading: Boolean,
    val errorMessage: String?,
    val pokemons: List<Pokemon>,
    val visiblePokemons: List<Pokemon>,
    val searchString: String?,
    val pageNumber: Int,
    val numberOfPages: Int
)

The actions

The actions in Redux correspond to events that can change the state of the application. We will implement the actions as sealed class hierarchy inherited from redux.RAction class.

sealed class PokeAction : RAction {
    object StartDownload : PokeAction()
    object DownloadOk : PokeAction()
    data class DownloadError(val errorMessage: String) : PokeAction()
    data class SetPokemonList(val pokemons: List<Pokemon>) : PokeAction()
    data class SetSearchString(val searchString: String?) : PokeAction()
    object NextPage : PokeAction()
    object PrevPage : PokeAction()
}

The actions with parameters are implemented as data classes and the others are just simple objects.

The reducer function

The reducer function describes how an action transforms the current state into the new state. It's the main place for the application logic.

To reuse some code we will create two helper extension functions. The first one filters the list of Pokemon objects with the given search string and returns original list if the string is null.

fun List<Pokemon>.filterBySearchString(searchString: String?): List<Pokemon> {
    return searchString?.let { search ->
        this.filter {
            it.name.toLowerCase().contains(search.toLowerCase())
        }
    } ?: this
}

The second function cuts a sublist from the original list based on the given page number. It uses the MAX_ON_PAGE constant, which is defined as 12, and do some very simple math.

fun List<Pokemon>.subListByPageNumber(pageNumber: Int): List<Pokemon> {
    return this.subList((pageNumber) * MAX_ON_PAGE, (pageNumber + 1) * MAX_ON_PAGE)
}

Writing the reducer function is now quite easy. The first four actions just change the state properties. Other actions are a bit more complicated - they calculate the new list of visible Pokémons and we use the helper functions defined above for that purpose. We can implement the whole pokedexReducer function as follows:

fun pokedexReducer(state: Pokedex, action: PokeAction): Pokedex = when (action) {
    is PokeAction.StartDownload -> state.copy(downloading = true)
    is PokeAction.DownloadOk -> state.copy(downloading = false)
    is PokeAction.DownloadError -> state.copy(downloading = false, errorMessage = action.errorMessage)
    is PokeAction.SetPokemonList -> state.copy(pokemons = action.pokemons)
    is PokeAction.SetSearchString -> {
        val filteredPokemon = state.pokemons.filterBySearchString(action.searchString)
        val visiblePokemons = filteredPokemon.take(MAX_ON_PAGE)
        state.copy(
            visiblePokemons = visiblePokemons,
            searchString = action.searchString,
            pageNumber = 0,
            numberOfPages = ((filteredPokemon.size - 1) / MAX_ON_PAGE) + 1
        )
    }
    is PokeAction.NextPage -> if (state.pageNumber < state.numberOfPages - 1) {
        val newPageNumber = state.pageNumber + 1
        val visiblePokemons = state.pokemons.filterBySearchString(state.searchString).subListByPageNumber(newPageNumber)
        state.copy(visiblePokemons = visiblePokemons, pageNumber = newPageNumber)
    } else {
        state
    }
    is PokeAction.PrevPage -> if (state.pageNumber > 0) {
        val newPageNumber = state.pageNumber - 1
        val visiblePokemons = state.pokemons.filterBySearchString(state.searchString).subListByPageNumber(newPageNumber)
        state.copy(visiblePokemons = visiblePokemons, pageNumber = newPageNumber)
    } else {
        state
    }
}

The store

Our data model is now almost complete. All that's left is to create the Redux store with some initial state. We use createReduxStore function for this.

val store = createReduxStore(::pokedexReducer, Pokedex(false, null, listOf(), listOf(), null, 0, 1))

We can now dispatch actions (or action creator functions) to the store. When the application starts, we have to download all Pokémons data from the PokéAPI servers. We call dispatch in the following way.

    store.dispatch(downloadPokemons())

    // ...

    private fun downloadPokemons(): ActionCreator<dynamic, Pokedex> {
        return { dispatch, _ ->
            val restClient = RestClient()
            dispatch(PokeAction.StartDownload)
            restClient.remoteCall(
                "https://pokeapi.co/api/v2/pokemon/", obj { limit = 800 },
                deserializer = Pokemon.serializer().list
            ) {
                it.results
            }.then { list ->
                dispatch(PokeAction.DownloadOk)
                dispatch(PokeAction.SetPokemonList(list))
                dispatch(PokeAction.SetSearchString(null))
            }.catch { e ->
                val info = if (!e.message.isNullOrBlank()) { " (${e.message})" } else { "" }
                dispatch(PokeAction.DownloadError("Service error!$info"))
            }
        }
    }

The downloadPokemons method returns ActionCreator function, which uses RestClient instance to call PokéAPI endpoints, receives the data and finally dispatches SetPokemonList and SetSearchString actions. It also handles the possible error in the downloading process.

Other actions are dispatched from the user interface elements, but we will take care of the GUI in the next part of this article. I will post it as soon as it's ready.

For now any feedback is highly appreciated.

Cheers :)

Part 2

Posted on by:

rjaros profile

Robert Jaros

@rjaros

Software developer and programming enthusiast from Poland. I like to reuse and integrate good stuff, made by other people and available as open source. Kind of mainstream adversary ;-)

Discussion

pic
Editor guide