Poké-Fun with Kotlin and Arrow

Welcome! In this guide we (well, actually you) are going to work on an application to build decks for the Pokémon Trading Card Game (TCG). Each chapter roughly corresponds to a different functionality: loading decks, searching cards, and so on.

The source code is available in this repository. Download or clone it, and you should be ready to go.

This book assumes that you know your way around Kotlin, but previous experience with functional programming or Arrow, or with Compose Multiplatform, is not required.

Compose is multi-plaftorm

For ease of development, the provided skeleton is a desktop application. Using Compose Multiplatform you can easily make it run in Android or iOS devices, with minor modifications.

The starting point introduces the domain and the main components of the technology.

  • If you have never heard of the Pokémon Trading Card Game or don't know the rules, start with the introduction to the domain;
  • If you are new to Amper or Compose Multiplatform, start with the technology,

Built with Amper

Poké-Fun uses Amper as build tool, as opposed to the most usual Gradle. In particular, you need to install the corresponding plug-in if you are using IntelliJ or Android Studio, although we recommend using Fleet.

Afterward, the overview describes the main components of the given code. The rest of the guide is divided into a series of more or less independent sections, so you can choose what you want to work on. Each section contains an introduction to one or more topics, and pointers to additional tutorials or documentation about them.

  • What is (in) a deck: model data using data classes and sealed hierarchies;
  • Law-abiding decks: check that the deck follows the rules, and learn about Raise along the way;
  • Deck building: design a good ViewModel using functional principles, and design undo/redo with actions-as-data;
  • Deal with bad internet: improve the experience with Schedule and CircuitBreaker, and cache results using memoization;
  • Loading and saving: store your work locally, and learn about parallel combinators in Arrow Fx;
  • Better architecture: introduce resource management, and overall nicer design;
  • Nicer UI: implement more visual feedback using Compose Multiplatform.

A word from our sponsor

Many of these sections complement the book Functional Programming Ideas for the Curious Kotliner.

Trading Card Games

The application in which we are going to work on, Poké-Fun, helps in the process of building decks for the Pokémon Trading Card Game (TCG). As usual in any software project, we first need to understand what all the words in the previous sentence mean; in other words, we need to dive into the domain.

In general, a Trading Card Game is a card game in which the set of cards is not fixed, as in Póker or Mus. In the case of Pokémon TCG, every year more than 500 new cards are introduced. In order to play, each player chooses a subset of card; this is known as their deck. The trading in TCG comes from the fact that traditionally you get the cards you need by exchanging them with your friends.

Most TCGs, and Pokémon is no exception, place some implicit and explicit restrictions on how decks may be built. Explicit restrictions include, among others, that your deck must contain exactly 60 cards. Other restrictions are implicit in the rules of the game; for example, a Pokémon deck cannot function with at least one basic Pokémon.

Once again we find a bunch of terms from the domain, what DDD practitioners call the Ubiquituous Language, so let's dive a bit more. Pokémon cards are divided in three big groups.

BulbasaurPokémon cards represent little monsters that fight against those of your opponent. Each Pokémon has one or more attacks, and health points (HP), which define how much damage they can take before fainting.
Grass EnergyEnergy cards are needed to power the attacks of Pokémon.
Great BallTrainer cards provide additional effects that you can use to help in the battle.

Pokémon and energies also have a type. There are currently 8 different types in the Pokémon TCG — grass , fire , water , lightning , fighting , psychic , metal , darkness , and dragon — alongside a special colorless type. In most cases a Pokémon of a certain type requires energy of the same type, but this is not a rule.

One key component of the Pokémon world is that the little monsters evolve, that is, the turn into bigger and more powerful creatures. In the TCG this is reflected by having to begin with basic Pokémon (hence the implicit requirement to have at least one in your deck), which then may turn into stage 1, and eventually in stage 2.

Apart from the type, one key attribute of cards is their name, since the rules have explicit restrictions on the amount of cards you can have with the same name. However, this does not mean that all cards with the same name are the same; for that reason each of them has an identifier to uniquely point to it.

Example

These are two different (happy) Oddish cards. Same name, different identifier, different attacks and HP.

Oddish 1 Oddish 2

This is enough for our purposes. If you are interested to learn how to play the game, check the official rulebook.

The technology

Poké-Fun is implemented using Kotlin, Arrow, and Compose Multiplatform. The latter has been chosen because it provides the same concepts to build user interfaces in a variety of platforms. In particular, we can write a desktop application that runs easily everywhere (the perks of using the JVM 😉).

The one choice which goes out of the ordinary is using Amper as build tool, instead of Gradle, much better-known among Kotliners. Feel free to look at the module.yaml file, but for the tasks you won't need to touch it. To start the application you can run ./amper run in a command line. The first time it may take some time to start, since build tools, compiler, and dependencies need to be set up.

We recommend using Fleet, IntelliJ IDEA, or Android Studio to work on Poké-Fun. If you use the latter, you need the corresponding Amper plug-in. In both cases, you should see a small play button to run the application from the IDE.

Run in Amper Fleet

Compose Multiplatform

In the recent years we have seen an explosion of a new paradigm for UI development, based on managing the state separately from the view, which is then defined as a function which is re-executed everytime the state changes. Some well-known frameworks include React for web, SwiftUI for iOS, and Jetpack Compose for Android. Compose Multiplatform uses the same concepts of the latter, but targeting several platforms (at the time of writing: desktop, Android, iOS, and web via WebAssembly).

More about Compose Multiplatform

There is still not much documentation about Compose Multiplatform, but most of the information about Jetpack Compose (for Android) applies only with minor modifications.

Compose applications are typically built from two components:

  • View models keep (part of) the state of the application, and communicate with the outside world.
  • Views define how this state is mapped into a set of UI elements laid out in the screen. Views are defined as functions with the @Composable annotation, which is required for the framework to be able to run them whenever the state (or part of it) changes.

Let us look at the simplest application: a button which counts how many times it has been pressed. The state is basically a counter. To define the read-only version we use property delegation.

class Counter: ViewModel() {
  // 1. define a state, starting with 0
  private val _count = mutableStateOf(0)

  // 2. expose the state in a read-only manner
  val count: Int by _count

  // 3. operations to change the state
  fun increment() {
    _count.value++
  }
}

The view consumes this view model, and shows a button with a text indicating the amount of times it has been clicked.

@Composable fun Screen(counter: Counter) {
  Button(onClick = { counter.increment() }) {
    Text("Clicked ${counter.count} times")
  }
}

What happens when the button is pressed? Then the onClick lambda is executed, which eventually changes the value of _count. Compose detects this change and recomposes the UI, that is, re-executes Screen and applies any update to the visible screen. As discussed above, the @Composable annotation (alongside the Compose compiler) is the magic that makes this link work.

Armed with this knowledge, you can read the introduction to Poké-Fun.

Overview of the code

Now that you know about the domain and the technology, we can describe the given implementation of Poké-Fun.

Cards

The tcg module gives access to cards and their information.

  • The tcg.kt file defines a set of types that represent the information in cards, including their name, identifier, category, and type. We shall delve on these types in the What is (in) a deck section.
  • Basic validation is implemented in validation.kt. In the Validation section we'll improve this functionality.

General design

The diagram below roughly represents how Poké-Fun is architected.

graph LR;
  PokemonTcgApi;
  SearchViewModel;
  PokemonTcgApi <-.- SearchViewModel;
  DeckViewModel;
  subgraph View [SplitPane]
    SearchPane;
    DeckPane;
  end
  SearchViewModel --- SearchPane;
  DeckViewModel --- SearchPane;
  DeckViewModel --- DeckPane;

In the center we find two different view models, which serve different purposes:

  • DeckViewModel (in deck/viewModel.kt) keeps track of the current status of the deck, including the cards contained in it, and the (potential) problems with that choice of cards.
  • SearchViewModel (in search/viewModel.kt) keeps track of the state of search, and is responsible for communicating with the Pokémon TCG API.

Access to the Pokémon TCG API is mediated by the PokemonTcgApi interface (in the tcg/api folder), for which we give a "real" implementation talking over the network using Ktor's client module, and a "fake" one with a few predefined cards. After finishing the Deal with bad internet section, we'll have some respectable code.

Two different views represent the data of the view models in a graphical manner. Those are put together in a single screen using a SplitPane, one of the desktop-specific components offered by Compose Multiplatform.

  • On the left-hand side we have the SearchPane (in search/view.kt), where the users input their search and see results. This view also adds selected cards to the deck, hence the dependence on the DeckViewModel.
  • On the right-hand side we have the DeckPane (in deck/view.kt), which simply shows the cards and problems.

Both view make use of common component to show a single Card and multiple Cards, found in tcg/cardView.kt. These components have an extra parameter which is used to provide the different elements required in each of the views (for example, the Add button in the search pane).

What is a deck

Topics: sealed hierachies, data classes, immutability

One of the key components in the functional approach to programming we promote is how we model the data. In other words, how we represent the information we care about throughout the execution of our application.

We prefer a immutable representation to one where mutation is available. This main benefit is at the level of reasoning, as it becomes much easier to understand what is going on and potential problems. If instead of modifying data we always transform it into a completely new value, we do not need to care about concurrent accesses. More bluntly, a whole source of potential bugs disappear when using immutability.

This property alone has a profound impact on our data types. Since there is no mutation, the values are stateless. Instead of thinking about modification, for example with person.setName("me"), we think in terms of transformation and copying, person.copy(name = "me"). Functional programmers are usually proud of their anemic domain models, in which operations always exist as transformations of data.

We also strive for a precise representation, which captures every possible invariant (domain rule) in our data. A prime example from the UI world is data which may also be loading or have errors while obtaining. One potential representation is given by

class Result(
  val data: Card?,
  val problem: Throwable?
)

with the additional invariant that at most one of the values should be non-null, and both being null represents a loading state.

This is problematic, though, because there is nothing stopping us from breaking that invariant. A more precise representation capture the three possible states as three different types in a sealed hierarchy,

sealed interface Result {
  data object Loading: Result
  data class Success(val data: Card): Result
  data class Problem(val problem: Throwable): Result
}

Now the compiler guarantees that the right information is present at each point. Furthermore, we gain the ability to use when to check the current state, and the compiler guarantees that we always handle all possible cases.

Sealed hierarchies are everywhere

The SearchStatus type used in search/viewModel.kt is quite similar to Result above. You can take a look at that file and the corresponding view to see how one operates with sealed hierarchies.

One nice advantage of using Compose is that it naturally leads to a more immutable representation of state. In the following tasks we focus on the precision of our domain model.

More on functional domain modeling

More precise type

The given domain model uses a nullable Type in Card. This is because not every card in the Pokémon TCG has a type; this attribute is restricted to Pokémon and basic Energy cards. Your task is to transform the given domain model to capture that invariant.

More precise energies

Even the previous refinement is not completely true. In fact, two types have some special meaning in the game:

  • Dragon may be the type of a Pokémon, but never the type of an Energy. In the game, this manifests as attacks never requiring "dragon energy"; dragon Pokémon always use a combination of other energies.
  • When colorless energy appears in a cost, it may be paid by any type of energy. There are no basic Colorless Energy card, but there are Colorless Pokémon.
KoraidonMiraidonThese cards are of dragon type, but their attacks do not use that energy (since it's forbidden). However, they both use colorless energy.
ChatotSnorlaxThese cards are of colorless type. They are used in every type of deck, since their attack cost can be paid using any energy.

Your task is to refine the given Type to account for these nuances. However, your solution should not be just two or more different types; by using inheritance you can create several subsets of types and share common cases.

Information about evolution

One of the most important features of the Pokémon franchise is that Pokémon evolve, that is, they turn into (stronger) Pokémon as they progress. This is mapped in the TCG as Stage 1 and Stage 2 Pokémon describing which Pokémon they evolve from.

One direction does not imply the other

Every Stage 1 or Stage 2 Pokémon evolves from exactly one Pokémon. However, the converse is not true: a single Pokémon may evolve to more than one Pokémon (or none). For example, Gloom may evolve into Vileplume and Bellossom, with Eevee having record eight different evolutions.

OddishGloomVileplumeBellossom

Your task is to refine the domain model to include this information. You need to also update the KtorPokemonTcgApi implementation to account for this extra attribute, check the Pokémon TCG API docs for the place where it appears.

kotlinx.serialization

The code uses kotlinx.serialization to transform the JSON returned by the API into Kotlin data classes. For more information, check the introduction and the basic guide.

As an additional task, you can improve the ordering of the deck shown in the right pane by taking evolution into account: evolution chains should appear together.

Law-abiding decks

Topics: validation, Raise, error accumulation

Handling errors is one of the scenarios where a functional approach shines. Using types like Either and contexts like Raise, we can easily compose larger validations from smaller ones.

This topic is well documented in the official Arrow documentation, we suggest the reader to check the following material:

Feel free to use any style that you prefer in this section. When in doubt, using Raise (as opposed to Either) is the preferred option when using Arrow.

Your task in this section is to implement the rules for a legal deck, that is, one that can be used to play Pokémon TCG. The tcg/validation.kt file contains a barebones implementation of validate, which simply checks the number of cards in the deck, and a non-empty title.

The main rules for the legality of a deck are:

  • The deck must contain exactly 60 cards,
  • There must be at most 4 cards with the same name,
    • Note that cards with different identifiers but the same name are added together,
    • The only exception to this rule are basic Energy cards, of which you can have an unlimited amount,
  • There must be at least one basic Pokémon.

Implement this validation using Either or Raise, and try to break the process in different functions. The notion of fail-first vs. accumulation is important here, so you can squeeze as much information as possible.

Extra task: implement a rule to check that you can always evolve every Pokémon in your deck. This means you if you have a Stage 1 or Stage 2 Pokémon, you should have a card for the Pokémon it evolves from.

Problems tied to specific cards

This first task simply gives back a list of string for each problem, but this approach goes against our aim of precise types. Your task here is introduce an error hierarchy that represents each possible problem with the deck. The transformation to string should now happen in the DeckPane view instead.

Extra task: show problems related to specific cards directly on them. For example, by showing the name in the MaterialTheme.colorScheme.error color. Think about how the information required in the error hierarchy.

Reactive problems

The current implementation has a potential problem: you need to update _problems every time you update _deck. But actually, the problems of a deck directly derive from the contents of the deck itself. Reactive frameworks like RxJava allow expressing this connection directly, and we can easily do the same using MutableState.

Your task is to replace each update to the _problems mutable state with a new definition based on _deck. You can use the function map in utils/mutableState.kt.

Map as in lists

An intuitive understanding for this operation arises if we look at a MutableState as a list of all the values as the time flows. In that way, the problems arise as mapping the validation over each element of that list.

Gym Leader Challenge

The rules described above correspond to the Standard format, which is the one sanctioned for tournaments. However, fans of the game have come with other formats, like Gym Leader Challenge (GLC). As an extra task, you may implement GLC rules.

  • You may need to add some UI element to specify the format your deck is in.
  • GLC forbids some sorts of cards, namely those with a Rule Box and ACE SPECs. This information is available from the API, but currently not reflected in the domain model.

Deck building

Topics: reducers, actions as data

This section dives a bit more in how we can see a view model from the lenses of an important topic in functional programming: reducers (also known as folds, or if you prefer a Greeker word, catamorphisms).

Let's look at the components of the DeckViewModel:

  • An initial state, comprised of Awesome Deck as title and an empty list of cards,
  • A series of operations (changeTitle, clear, add) which transform this state.

Using these two elements, we can understand the whole lifetime of the application as consecutive steps, each transforming the previous state.

graph LR;
  State1[State 1];
  State2[State 2];
  State3[State 3];
  State4[...];
  Initial -->|add| State1;
  State1 -->|changeTitle| State2;
  State2 -->|add| State3;
  State3 -->|add| State4;

To understand why we call this the reducer model, let's take a small leap of faith, and assume we somehow represent the sequence of transformations as a list (no worries, we are making this real in a few paragraphs). Then the current state of the system is define using the fold operation over the list of transformations until that point.

val currentState =
  transformationsUntilNow.fold(initialState) { state, transformation ->
    transformation.apply(state)
  }

Fold and reduce

In Kotlin, the difference between fold and reduce is that in the former you provide an initial state, whereas in the latter the initial state is the first element in the list. But this is not as clear cut in other programming languages. For example, Redux is one of the libraries that popularized this concept in JavaScript.

Instead of using methods, we can express each of the available operations as data. We call this technique actions as data, or with a fancier term, reification. The description of each action should contain enough information to apply the operation to the current state. For our view model, we obtain:

sealed interface DeckOperation {
  data class ChangeTitle(val newTitle: String): DeckOperation
  data class AddCard(val card: Card): DeckOperation
  data object Clear: DeckOperation
}

Now our view model can use a single function, and dispatch based on the operation.

class DeckViewModel: ViewModel() {
  fun apply(operation: DeckOperation) = when (operation) {
    is DeckOperation.ChangeTitle -> { ... }
    is DeckOperation.AddCard -> { ... }
    DeckOperation.Clear -> { ... }
  }
}

In the tasks below we use this idea to improve the implementation, and introduce new functionality.

Initial style DSLs

Actions as data is the beginning of a journey to domain specific languages (DSLs), the idea of introducing a small language to describe your specific domain. In particular, actions as data relate to a particular technique to implement DSLs, called initial style. Inikio is a compiler plug-in to facilitate the development of such DSLs in Kotlin.

Move to actions as data

Your task is to finish the conversion of the given code into an actions-as-data-based approach. That is, copy (and extend if necessary) the DeckOperation type given above, and change the view model to use a single point of entry apply to every transformation.

Keeping the current state

Even though you can keep just the list of actions that were performed, and apply them whenever the current state is required, this choice usually leads to bad performance. We strongly recommend that you keep the same MutableState as you have now.

Remove a card

Right now the only option the users of Poké-Fun have if they have added a card they do not like is to clear the entire deck 🫠 Your task is to implement functionality to remove a card from the deck: this involes changes in both view model and view.

Tip

Take a look at search/view.kt to see how to add components to each card shown on the screen.

Undo and redo

One functionality which becomes much easier to implement when operations are reified as data is undo and redo, since you can very easily keep track of what the user has done.

Your task is to finish the implementation: the given view contains buttons for the actions, but they do nothing and are never enabled. At the end, the corresponding buttons in the view should only be enabled when there are operations to undo (or redo, respectively).

Deal with bad internet

Topics: resilience, Schedule, circuit breaker, caching

The given implementation of Poké-Fun works fine... if the internet connection is fine (extra points for irony if the room where you are working on this tasks has bad internet). Any realistic application that accesses other services must protect itself against potential disconnections, lags, or services which are down. We refer with the term resilience to all those techniques which help in providing a better experience in problematic scenarios.

This topic is well documented in the official Arrow documentation, we suggest the reader to check the Resilience section, both the introduction and the pages describing Schedule and CircuitBreaker.

Decorator

We strongly recommend to use the Decorator pattern in the following tasks. For a good introduction, fully in Kotlin, check this video by Dave Leeds.

Retry if fails

The task here is to create a wrapper that adds retry capabilities to an inner PokemonTcgApi instance. Explore different variations of the Schedule, from a simple fixed repetition, to exponential backoff policies.

Use a circuit breaker

Improve the previous soltuion with a circuit breaker, which ensures that we do not overload the service or the client in case the (transient) failure takes a long time to recover.

As described in the documentation, the best option for resilience is to combine both approaches.

A common pattern to make resilient systems is to compose a circuit breaker with a backing-off policy that prevents the resource from overloading. Schedule is insufficient to make your system resilient because you also have to consider parallel calls to your functions. In contrast, a circuit breaker track failures of every function call or even different functions to the same resource or service.

Introduce a cache

The given implementation queries the Pokémon TCG API service for every search and every card. However, cards with an existing identifier almost never change (except for errata), so there is no need to get them over and over. Searches also change rarely: new sets with additional cards only appear every 3 months, and we do not expect our users to stay that long in the application.

Introducing a cache improves performance, and also makes our application more resilient, since fewer calls need to communicate outside. Your task is to introduce Cache4k, a nice Kotlin Multiplatform option for this matter.

Caching and memoization

Memoization is a concept in functional programming very related to caching. If you have a completely pure function — no other effect except computation — then every run with the same input argument should give you the same output. As a result, there is no need to run the function twice. This becomes especially relevant for recursive functions like Fibonacci. Arrow comes with MemoizedDeepRecursiveFunction, which improved over the usual deep recursion support in Kotlin.

The task at hand, however, is not really in the realm of memoization. First of all, we do not have a pure computation, but rather data coming from external sources. In addition, recursion is completely absent from API calls.

Loading and saving

Topics: parallel operations, Raise + exceptions + concurrency

Poké-Fun as provided has a very big limitation. You can only work on your deck in one go: if you want to devote several sessions to it, you must either (1) not close the application, or (2) write down your cards in a piece of paper and add them back the next time. In this section we add support for loading and saving deck files, and learn about high-level parallelism on the way.

Load and store

The first task is to implement saving the deck as a file, and being able to read it back afterward. Feel free to choose whatever format you like, from the list of identifiers separated by new lines, to some sort of JSON.

The code provided in deck/view.kt integrates FileKit to show the file picker of the platform the application is running on. The button are disabled, remember to set enabled = true in the call to IconButton.

Saving the deck is easy, but loading it potentially involves getting the information from each of them. You should use the getById method from PokemonTcgApi to retrieve the Card corresponding to a given identifier.

In a first approximation, using map over the sequence of identifiers should be enough. Albeit simple, that solution lacks performance. Arrow provides high-level concurrency which solves the problem quite succintly. Use parMap to turn the sequential iteration into a concurrent set of operations.

Another problem with the simple approach, depending on how you store the data from a deck, is that you may ask information about the same card more than once. One potential solution is to group the cards by identifier, but a more general approach is to use caching that works independently of the number of consumers.

From exceptions to Raise

Problems may arise during the retrieval of card information, but the current code is not prepared for that eventuality. In this section we improve the situation by using Raise.

About Raise

It is strongly recommended to read the Law-abiding decks section, which introduces the basics of Raise, before attempting the following task.

The integration of parMap (and parZip) with Raise and error accumulation is discussed in the Arrow documentation. Although the TL;DR is simply "replace mapOrAccumulate with parMapOrAccumulate and enjoy".

Your task is to use Either.catch to capture any potential exceptions, and transform then into Either. As hinted in the Law-abiding decks section, you need to define an error hierarchy to represent those problems.

Several error hierarchies

It is not necessary to have a single error hierarchy for the entire domain. You only need a common parent whenever you may be mixing those in a single function, which means your error hierarchy is actually shared by two parts of the domain.

By default, using the either builder means following a fail-first approach to errors. If you have not done it directly on the previous task, change the behavior to accumulation. In other words, you should report every problem you find loading a file, not only the first identifier you fail to obtain.

Better architecture

Topics: resource management, SuspendApp

Not all the work you do in an application directly translates into new features. Ensuring that the code remains understandable at the macro and micro level is an important task of a good developer.

Scouts rule

Leave something slightly better of than you found it.

As explicit as possible

One of the mottos of the style of functional programming we promote is making explicit as much of the function behavior as possible. In statically-typed languages like Kotlin, explicit means in the function signature. The information we want to explicit in functions are, among others:

  1. Services and resources required to execute the function. Those may be provided as simple arguments, or using extension receivers (and in the future, with context parameters).
  2. Whether the function is pure (in other words, it is just computation) or may perform side effects. In the latter case, we mark the function with suspend.

A longer explanation, including more examples of the usage of receivers, can be found in the Arrow documentation.

One downside which is often mentioned of that style is that dependencies need to be manually injected. That is, the developer creates the instances of every service used by the application, as opposed to using a dependency injection (DI) framework like Koin and Hilt. However, we don't see this as a downside: by taking control of the creation of services we end up with simpler logic, minimize the amount of inter-dependencies, and avoid runtime or compile-time costs associated to DI frameworks.

Resource management

One of the challenges with this style of programming is managing the acquisition and release of resources and services. One of the problems is too much nesting in their creation,

HttpClient().use { client ->
  KtorPokemonTcgApi(client).use { api ->
    // now start the application
  }
}

Arrow solves this problem with resourceScope blocks. The code above can be rewritten with any nesting, given that they implement AutoCloseable.

resourceScope {
  val client = autoCloseable { HttpClient() }
  val api = autoCloseable { KtorPokemonTcgApi(client) }
  // now start the application
}

One step further is SuspendApp, which adds graceful shutdown to the whole application. By combining SuspendApp with Resource, you can ensure that finalizers runs correctly, even when the application is terminated.

Poké-Resources

Your task is to improve the current architecture of the application by introducing Resource and SuspendApp. Feel free to change the service constructors from the more implicit version provided to a more explicit version; for example, creating the HttpClient for KtorPokemonTcpApi explicitly.

Nicer UI

Topics: Compose, navigation

Compose Multiplatform is a great UI library based of functional principles. Although more tutorials and guides are slowly hitting the shelves, most of the material about Jetpack Compose (the Android version) still applies here. In this section we propose a couple of tasks in case you want to dive further in the UI side of things.

Right now Poké-Fun only searches cards by name. However, the API has many more options, so you can filter with respect to the different attributes in a card. For example, the player may want to look for cards of a specific type to build a thematic deck.

Your task is to provide an advanced search view (you can look at the official card database for inspiration). This requires changes in a few places:

  • You have to extend search method in the PokemonTcgApi interface to take the additional information as input.
  • You have to modify the Ktor implementation to generate the correct query string.
  • You have to provide additional UI elements for the player to change the filters.

To help with the UI side of things, the provided Type enumeration already contains the URL of the image corresponding to each of the types.

Card detail view

Sometimes you may want to check the text on a card, but Poké-Fun does not make that easy, since the deck pane focuses on an overview of the deck. Your task is to add a way to show a detailed view; for example, when the card is (double) clicked.

We encourage you to use the navigation library provided by Compose. To help you get started, we provide a very basic version in deck/navigation.kt. As you can see, navigation is done via routes, which are serializable classes representing a particular screen and data. Inside each of them, you define how the UI should look like, as we've been doing until now.

composable<Routes.Main> {
  DeckPane(deck, modifier)
}
composable<Routes.Detail> { entry ->
  // get the value of the argument
  val cardId = entry.toRoute<Routes.Detail>().cardId
  // build the UI from this information
}

Type safe routes

Versions of Compose Navigation prior to 2.8.0 (which corresponds to Compose Multiplatform 1.7.0) used strings instead of serializable objects to define routes. One more instance where type safety is a welcome addition.

Debouncing

The current implementation initiates a search everytime the user types something in the corresponding field. In practice, though, people type a few characters in a row, so every connection before the last one is wasted time. Most applications implement debouncing as a strategy for providing good user experience but prevent needless work.

Your task is to apply that technique to Poké-Fun. There are several options to do so, this guide discusses the most straightforward ones in the context of Compose.