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.
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,
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
andCircuitBreaker
, 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.
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.
Poké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. | |
Energy cards are needed to power the attacks of Pokémon. | |
Trainer 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.
These are two different (happy) Oddish cards. Same name, different identifier, different attacks and HP.
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.
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).
There is still not much documentation about Compose Multiplatform, but most of the information about Jetpack Compose (for Android) applies only with minor modifications.
- Android Basics with Compose,
- Jetpack Compose guides from Google,
- Create a Compose Multiplarform app,
- Philipp Lackner has videos covering Compose Multiplarform.
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
(indeck/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
(insearch/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
(insearch/view.kt
), where the users input their search and see results. This view also adds selected cards to the deck, hence the dependence on theDeckViewModel
. - On the right-hand side we have the
DeckPane
(indeck/view.kt
), which simply shows the cards and problems.
Both view make use of common component to show a single Card
and multiple Card
s, 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.
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.
- Domain modeling in Arrow documentation.
- The book Domain modeling made functional by Scott Wlaschin introduces many of these ideas in the context of F#, but it maps quite well to Kotlin.
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.
These cards are of dragon type, but their attacks do not use that energy (since it's forbidden). However, they both use colorless energy. | ||
These 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.
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.
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.
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.
Legal decks
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
.
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 map
ping 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)
}
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.
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.
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.
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
.
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.
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
.
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.
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.
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:
- 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).
- 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.
Better search
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 thePokemonTcgApi
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
}
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.