├── .gitignore ├── docs ├── favicon.png ├── .nojekyll ├── images │ ├── fire.png │ ├── grass.png │ ├── metal.png │ ├── water.png │ ├── dragon.png │ ├── psychic.png │ ├── amper-run.png │ ├── colorless.png │ ├── darkness.png │ ├── fighting.png │ ├── lightning.png │ └── intellij-run.png ├── FontAwesome │ └── fonts │ │ ├── FontAwesome.ttf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 ├── fonts │ ├── open-sans-v17-all-charsets-300.woff2 │ ├── open-sans-v17-all-charsets-600.woff2 │ ├── open-sans-v17-all-charsets-700.woff2 │ ├── open-sans-v17-all-charsets-800.woff2 │ ├── open-sans-v17-all-charsets-italic.woff2 │ ├── open-sans-v17-all-charsets-regular.woff2 │ ├── open-sans-v17-all-charsets-300italic.woff2 │ ├── open-sans-v17-all-charsets-600italic.woff2 │ ├── open-sans-v17-all-charsets-700italic.woff2 │ ├── open-sans-v17-all-charsets-800italic.woff2 │ ├── source-code-pro-v11-all-charsets-500.woff2 │ ├── fonts.css │ ├── SOURCE-CODE-PRO-LICENSE.txt │ └── OPEN-SANS-LICENSE.txt ├── css │ ├── print.css │ ├── general.css │ └── variables.css ├── theme │ ├── mermaid-init.js │ └── highlight.css ├── ayu-highlight.css ├── highlight.css ├── favicon.svg ├── tomorrow-night.css ├── toc.html ├── toc.js ├── clipboard.min.js └── 404.html ├── guide ├── images │ ├── fire.png │ ├── dragon.png │ ├── grass.png │ ├── metal.png │ ├── water.png │ ├── amper-run.png │ ├── colorless.png │ ├── darkness.png │ ├── fighting.png │ ├── lightning.png │ ├── psychic.png │ └── intellij-run.png ├── SUMMARY.md ├── local.md ├── intro.md ├── welcome.md ├── cmp.md ├── architecture.md ├── tcg.md ├── par.md ├── validation.md ├── resilience.md ├── tech-intro.md ├── build.md └── adt.md ├── composeResources └── font │ └── pokemon-rs.ttf ├── .gitmodules ├── src ├── utils │ ├── mutableState.kt │ └── splitter.kt ├── tcg │ ├── validation.kt │ ├── api │ │ ├── api.kt │ │ ├── ktor.kt │ │ ├── local.kt │ │ └── common.kt │ ├── cardView.kt │ └── tcg.kt ├── deck │ ├── navigation.kt │ ├── viewModel.kt │ └── view.kt ├── theme │ ├── Type.kt │ ├── Color.kt │ └── Theme.kt ├── search │ ├── viewModel.kt │ └── view.kt └── main.kt ├── book.toml ├── README.md ├── theme ├── mermaid-init.js └── highlight.css ├── module.yaml ├── amper.bat └── amper /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | .DS_Store 3 | local.properties 4 | .idea -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/favicon.png -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | This file makes sure that Github Pages doesn't process mdBook's output. 2 | -------------------------------------------------------------------------------- /docs/images/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/fire.png -------------------------------------------------------------------------------- /docs/images/grass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/grass.png -------------------------------------------------------------------------------- /docs/images/metal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/metal.png -------------------------------------------------------------------------------- /docs/images/water.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/water.png -------------------------------------------------------------------------------- /guide/images/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/fire.png -------------------------------------------------------------------------------- /docs/images/dragon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/dragon.png -------------------------------------------------------------------------------- /docs/images/psychic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/psychic.png -------------------------------------------------------------------------------- /guide/images/dragon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/dragon.png -------------------------------------------------------------------------------- /guide/images/grass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/grass.png -------------------------------------------------------------------------------- /guide/images/metal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/metal.png -------------------------------------------------------------------------------- /guide/images/water.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/water.png -------------------------------------------------------------------------------- /docs/images/amper-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/amper-run.png -------------------------------------------------------------------------------- /docs/images/colorless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/colorless.png -------------------------------------------------------------------------------- /docs/images/darkness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/darkness.png -------------------------------------------------------------------------------- /docs/images/fighting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/fighting.png -------------------------------------------------------------------------------- /docs/images/lightning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/lightning.png -------------------------------------------------------------------------------- /guide/images/amper-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/amper-run.png -------------------------------------------------------------------------------- /guide/images/colorless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/colorless.png -------------------------------------------------------------------------------- /guide/images/darkness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/darkness.png -------------------------------------------------------------------------------- /guide/images/fighting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/fighting.png -------------------------------------------------------------------------------- /guide/images/lightning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/lightning.png -------------------------------------------------------------------------------- /guide/images/psychic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/psychic.png -------------------------------------------------------------------------------- /docs/images/intellij-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/images/intellij-run.png -------------------------------------------------------------------------------- /guide/images/intellij-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/guide/images/intellij-run.png -------------------------------------------------------------------------------- /composeResources/font/pokemon-rs.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/composeResources/font/pokemon-rs.ttf -------------------------------------------------------------------------------- /docs/FontAwesome/fonts/FontAwesome.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/FontAwesome/fonts/FontAwesome.ttf -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pokemon-tcg-data"] 2 | path = pokemon-tcg-data 3 | url = https://github.com/PokemonTCG/pokemon-tcg-data.git 4 | -------------------------------------------------------------------------------- /docs/FontAwesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/FontAwesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /docs/FontAwesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/FontAwesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /docs/FontAwesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/FontAwesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /docs/FontAwesome/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/FontAwesome/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-300.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-600.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-700.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-800.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-italic.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-regular.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-300italic.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-600italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-600italic.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-700italic.woff2 -------------------------------------------------------------------------------- /docs/fonts/open-sans-v17-all-charsets-800italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/open-sans-v17-all-charsets-800italic.woff2 -------------------------------------------------------------------------------- /docs/fonts/source-code-pro-v11-all-charsets-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serras/poke-fun/HEAD/docs/fonts/source-code-pro-v11-all-charsets-500.woff2 -------------------------------------------------------------------------------- /src/utils/mutableState.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import androidx.compose.runtime.State 4 | 5 | fun State.map(transform: (T) -> S): State = object : State { 6 | override val value: S 7 | get() = transform(this@map.value) 8 | } -------------------------------------------------------------------------------- /src/tcg/validation.kt: -------------------------------------------------------------------------------- 1 | package tcg 2 | 3 | import arrow.core.NonEmptyList 4 | import arrow.core.toNonEmptyListOrNull 5 | 6 | fun Deck.validate(): NonEmptyList? = buildList { 7 | if (cards.size > 60) add("Too many cards") 8 | if (cards.size < 60) add("More cards needed") 9 | if (title.isBlank()) add("Title is blank") 10 | }.toNonEmptyListOrNull() -------------------------------------------------------------------------------- /guide/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Welcome](./welcome.md) 4 | 5 | # Introduction 6 | 7 | - [Trading Card Games](./tcg.md) 8 | - [The technology](./tech-intro.md) 9 | - [Overview of the code](./intro.md) 10 | 11 | # Practice 12 | 13 | - [What is (in) a deck](./adt.md) 14 | - [Law-abiding decks](./validation.md) 15 | - [Deck building](./build.md) 16 | - [Deal with bad internet](./resilience.md) 17 | - [Using local data](./local.md) 18 | - [Loading and saving](./par.md) 19 | - [Better architecture](./architecture.md) 20 | - [Nicer UI](./cmp.md) -------------------------------------------------------------------------------- /book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Alejandro Serrano Mena"] 3 | language = "en" 4 | multilingual = false 5 | src = "guide" 6 | 7 | [build] 8 | build-dir = "docs" 9 | 10 | [preprocessor.admonish] 11 | command = "mdbook-admonish" 12 | assets_version = "3.0.3" # do not edit: managed by `mdbook-admonish install` 13 | 14 | [preprocessor.mermaid] 15 | command = "mdbook-mermaid" 16 | 17 | [output] 18 | 19 | [output.html] 20 | additional-css = ["./theme/highlight.css", "./theme/mdbook-admonish.css"] 21 | additional-js = ["./theme/mermaid.min.js", "./theme/mermaid-init.js"] 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Poké-Fun with Kotlin and Arrow 2 | 3 | > Learn functional programming principles in [Kotlin](https://kotlinlang.org/), using [Arrow](https://arrow-kt.io/) and [Compose Multiplatform](https://www.jetbrains.com/lp/compose-multiplatform/). 4 | 5 | ## [Read the guide](https://serranofp.com/poke-fun/) 6 | 7 | The book is being developed using [mdBook](https://rust-lang.github.io/mdBook/) with the [admonish](https://github.com/tommilligan/mdbook-admonish) and [mermaid](https://github.com/badboy/mdbook-mermaid) plug-ins (both `mdbook-admonish` and `mdbook-mermaid` should live in your `PATH`). -------------------------------------------------------------------------------- /guide/local.md: -------------------------------------------------------------------------------- 1 | # Using local data 2 | 3 | > **Topics**: optics, JSON manipulation 4 | 5 | Until this point we have focused on requesting data from a remote API. In some scenarios such requests are not possible or desirable, and a local data source is a better option. When you clone the repository for these exercises, you should find a `pokemon-tcg-data` submodule that contains a [copy of the data for the remote API](https://github.com/PokemonTCG/pokemon-tcg-data). 6 | 7 | ## Targeting data with optics 8 | 9 | The current implementation is extremely inefficient: it transforms every card in every `.json` file into a `Card` object, and then filters the results. A better solution is to perform the query directly on the JSON document, and only transform the data if it matches. 10 | 11 | ## Understanding `forEachCard` -------------------------------------------------------------------------------- /docs/css/print.css: -------------------------------------------------------------------------------- 1 | 2 | #sidebar, 3 | #menu-bar, 4 | .nav-chapters, 5 | .mobile-nav-chapters { 6 | display: none; 7 | } 8 | 9 | #page-wrapper.page-wrapper { 10 | transform: none !important; 11 | margin-inline-start: 0px; 12 | overflow-y: initial; 13 | } 14 | 15 | #content { 16 | max-width: none; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | .page { 22 | overflow-y: initial; 23 | } 24 | 25 | code { 26 | direction: ltr !important; 27 | } 28 | 29 | pre > .buttons { 30 | z-index: 2; 31 | } 32 | 33 | a, a:visited, a:active, a:hover { 34 | color: #4183c4; 35 | text-decoration: none; 36 | } 37 | 38 | h1, h2, h3, h4, h5, h6 { 39 | page-break-inside: avoid; 40 | page-break-after: avoid; 41 | } 42 | 43 | pre, code { 44 | page-break-inside: avoid; 45 | white-space: pre-wrap; 46 | } 47 | 48 | .fa { 49 | display: none !important; 50 | } 51 | -------------------------------------------------------------------------------- /src/deck/navigation.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("PLUGIN_IS_NOT_ENABLED") 2 | package deck 3 | 4 | import androidx.compose.material3.Text 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.mutableStateListOf 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.navigation3.runtime.NavKey 10 | import androidx.navigation3.runtime.entryProvider 11 | import androidx.navigation3.ui.NavDisplay 12 | import kotlinx.serialization.Serializable 13 | 14 | sealed interface Routes : NavKey { 15 | @Serializable data object Main : Routes 16 | @Serializable data class Detail(val cardId: String) : Routes 17 | } 18 | 19 | @Composable 20 | fun DeckPaneWithDetails( 21 | deck: DeckViewModel, 22 | modifier: Modifier = Modifier 23 | ) { 24 | val backStack = remember { mutableStateListOf(Routes.Main) } 25 | 26 | NavDisplay( 27 | backStack = backStack, 28 | entryProvider = entryProvider { 29 | entry { 30 | DeckPane(deck, modifier) 31 | } 32 | entry { 33 | Text("Card with id ${it.cardId}") 34 | } 35 | } 36 | ) 37 | } -------------------------------------------------------------------------------- /theme/mermaid-init.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const darkThemes = ['ayu', 'navy', 'coal']; 3 | const lightThemes = ['light', 'rust']; 4 | 5 | const classList = document.getElementsByTagName('html')[0].classList; 6 | 7 | let lastThemeWasLight = true; 8 | for (const cssClass of classList) { 9 | if (darkThemes.includes(cssClass)) { 10 | lastThemeWasLight = false; 11 | break; 12 | } 13 | } 14 | 15 | const theme = lastThemeWasLight ? 'default' : 'dark'; 16 | mermaid.initialize({ startOnLoad: true, theme }); 17 | 18 | // Simplest way to make mermaid re-render the diagrams in the new theme is via refreshing the page 19 | 20 | for (const darkTheme of darkThemes) { 21 | document.getElementById(darkTheme).addEventListener('click', () => { 22 | if (lastThemeWasLight) { 23 | window.location.reload(); 24 | } 25 | }); 26 | } 27 | 28 | for (const lightTheme of lightThemes) { 29 | document.getElementById(lightTheme).addEventListener('click', () => { 30 | if (!lastThemeWasLight) { 31 | window.location.reload(); 32 | } 33 | }); 34 | } 35 | })(); 36 | -------------------------------------------------------------------------------- /docs/theme/mermaid-init.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const darkThemes = ['ayu', 'navy', 'coal']; 3 | const lightThemes = ['light', 'rust']; 4 | 5 | const classList = document.getElementsByTagName('html')[0].classList; 6 | 7 | let lastThemeWasLight = true; 8 | for (const cssClass of classList) { 9 | if (darkThemes.includes(cssClass)) { 10 | lastThemeWasLight = false; 11 | break; 12 | } 13 | } 14 | 15 | const theme = lastThemeWasLight ? 'default' : 'dark'; 16 | mermaid.initialize({ startOnLoad: true, theme }); 17 | 18 | // Simplest way to make mermaid re-render the diagrams in the new theme is via refreshing the page 19 | 20 | for (const darkTheme of darkThemes) { 21 | document.getElementById(darkTheme).addEventListener('click', () => { 22 | if (lastThemeWasLight) { 23 | window.location.reload(); 24 | } 25 | }); 26 | } 27 | 28 | for (const lightTheme of lightThemes) { 29 | document.getElementById(lightTheme).addEventListener('click', () => { 30 | if (!lastThemeWasLight) { 31 | window.location.reload(); 32 | } 33 | }); 34 | } 35 | })(); 36 | -------------------------------------------------------------------------------- /src/tcg/api/api.kt: -------------------------------------------------------------------------------- 1 | package tcg.api 2 | 3 | import kotlin.time.Duration.Companion.seconds 4 | import kotlinx.coroutines.delay 5 | import tcg.Card 6 | import tcg.Category 7 | import tcg.PokemonStage 8 | import tcg.Type 9 | 10 | interface PokemonTcgApi { 11 | suspend fun search(name: String): List 12 | suspend fun getById(identifier: String): Card? 13 | } 14 | 15 | object FakePokemonTcgApi: PokemonTcgApi { 16 | override suspend fun search(name: String): List { 17 | delay(3.seconds) 18 | return FakeCards.filter { name.contains(name, ignoreCase = true) } 19 | } 20 | 21 | override suspend fun getById(identifier: String): Card? { 22 | delay(1.seconds) 23 | return FakeCards.firstOrNull { it.identifier == identifier } 24 | } 25 | 26 | 27 | val FakeCards: List = listOf( 28 | Card("Bulbasaur", "sv3pt5-1", Category.Pokemon(PokemonStage.Basic), Type.Grass, "I"), 29 | Card("Charmander", "sv3pt5-4", Category.Pokemon(PokemonStage.Basic), Type.Fire, "I"), 30 | Card("Squirtle", "sv3pt5-7", Category.Pokemon(PokemonStage.Basic), Type.Water, "I"), 31 | Card("Caterpie", "sv3pt5-10", Category.Pokemon(PokemonStage.Basic), Type.Grass, "I"), 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /docs/ayu-highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | Based off of the Ayu theme 3 | Original by Dempfi (https://github.com/dempfi/ayu) 4 | */ 5 | 6 | .hljs { 7 | display: block; 8 | overflow-x: auto; 9 | background: #191f26; 10 | color: #e6e1cf; 11 | } 12 | 13 | .hljs-comment, 14 | .hljs-quote { 15 | color: #5c6773; 16 | font-style: italic; 17 | } 18 | 19 | .hljs-variable, 20 | .hljs-template-variable, 21 | .hljs-attribute, 22 | .hljs-attr, 23 | .hljs-regexp, 24 | .hljs-link, 25 | .hljs-selector-id, 26 | .hljs-selector-class { 27 | color: #ff7733; 28 | } 29 | 30 | .hljs-number, 31 | .hljs-meta, 32 | .hljs-builtin-name, 33 | .hljs-literal, 34 | .hljs-type, 35 | .hljs-params { 36 | color: #ffee99; 37 | } 38 | 39 | .hljs-string, 40 | .hljs-bullet { 41 | color: #b8cc52; 42 | } 43 | 44 | .hljs-title, 45 | .hljs-built_in, 46 | .hljs-section { 47 | color: #ffb454; 48 | } 49 | 50 | .hljs-keyword, 51 | .hljs-selector-tag, 52 | .hljs-symbol { 53 | color: #ff7733; 54 | } 55 | 56 | .hljs-name { 57 | color: #36a3d9; 58 | } 59 | 60 | .hljs-tag { 61 | color: #00568d; 62 | } 63 | 64 | .hljs-emphasis { 65 | font-style: italic; 66 | } 67 | 68 | .hljs-strong { 69 | font-weight: bold; 70 | } 71 | 72 | .hljs-addition { 73 | color: #91b362; 74 | } 75 | 76 | .hljs-deletion { 77 | color: #d96c75; 78 | } 79 | -------------------------------------------------------------------------------- /src/deck/viewModel.kt: -------------------------------------------------------------------------------- 1 | package deck 2 | 3 | import androidx.compose.runtime.MutableState 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.snapshots.Snapshot 7 | import androidx.lifecycle.ViewModel 8 | import arrow.core.NonEmptyList 9 | import tcg.Card 10 | import tcg.Deck 11 | import tcg.validate 12 | 13 | class DeckViewModel: ViewModel() { 14 | private val _deck = mutableStateOf(Deck.INITIAL) 15 | val deck: Deck by _deck 16 | 17 | private val _problems = mutableStateOf(deck.validate()) 18 | val problems: NonEmptyList? by _problems 19 | 20 | fun changeTitle(newTitle: String) { 21 | _deck.update { it.copy(title = newTitle) } 22 | _problems.value = deck.validate() 23 | } 24 | 25 | fun clear() { 26 | _deck.update { it.copy(cards = emptyList()) } 27 | _problems.value = deck.validate() 28 | } 29 | 30 | fun add(card: Card) { 31 | _deck.update { it.copy(cards = it.cards + card) } 32 | _problems.value = deck.validate() 33 | } 34 | } 35 | 36 | 37 | // FROM ARROW-OPTICS-COMPOSE 38 | /** 39 | * Modifies the value in this [MutableState] 40 | * by applying the function [block] to the current value. 41 | */ 42 | public inline fun MutableState.update(crossinline block: (T) -> T) { 43 | Snapshot.withMutableSnapshot { 44 | value = block(value) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/splitter.kt: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.Color 7 | import androidx.compose.ui.input.pointer.PointerIcon 8 | import androidx.compose.ui.input.pointer.pointerHoverIcon 9 | import androidx.compose.ui.unit.dp 10 | import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi 11 | import org.jetbrains.compose.splitpane.SplitterScope 12 | import org.jetbrains.skiko.Cursor 13 | 14 | @OptIn(ExperimentalSplitPaneApi::class) 15 | fun SplitterScope.HorizontalSplitPaneSplitter() { 16 | visiblePart { 17 | Box(Modifier.requiredWidth(1.dp).fillMaxHeight().background(Color.Transparent)) 18 | } 19 | handle { 20 | Box( 21 | Modifier.markAsHandle().pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) 22 | .requiredWidth(1.dp).fillMaxHeight().background(Color.Gray) 23 | ) 24 | } 25 | } 26 | 27 | @OptIn(ExperimentalSplitPaneApi::class) 28 | fun SplitterScope.VerticalSplitPaneSplitter() { 29 | visiblePart { 30 | Box(Modifier.requiredHeight(1.dp).fillMaxWidth().background(Color.Transparent)) 31 | } 32 | handle { 33 | Box( 34 | Modifier.markAsHandle().pointerHoverIcon(PointerIcon(Cursor(Cursor.N_RESIZE_CURSOR))) 35 | .requiredHeight(1.dp).fillMaxWidth().background(Color.Gray) 36 | ) 37 | } 38 | } -------------------------------------------------------------------------------- /src/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.font.FontFamily 5 | import androidx.compose.ui.text.platform.Font 6 | 7 | // should be FontFamily(Font(Res.font.pokemon_rs)) 8 | val pokemonRs = FontFamily(Font("composeResources/resources/font/pokemon-rs.ttf")) 9 | 10 | val bodyFontFamily = pokemonRs 11 | val displayFontFamily = pokemonRs 12 | 13 | // Default Material 3 typography values 14 | val baseline = Typography() 15 | 16 | val AppTypography = Typography( 17 | displayLarge = baseline.displayLarge.copy(fontFamily = displayFontFamily), 18 | displayMedium = baseline.displayMedium.copy(fontFamily = displayFontFamily), 19 | displaySmall = baseline.displaySmall.copy(fontFamily = displayFontFamily), 20 | headlineLarge = baseline.headlineLarge.copy(fontFamily = displayFontFamily), 21 | headlineMedium = baseline.headlineMedium.copy(fontFamily = displayFontFamily), 22 | headlineSmall = baseline.headlineSmall.copy(fontFamily = displayFontFamily), 23 | titleLarge = baseline.titleLarge.copy(fontFamily = displayFontFamily), 24 | titleMedium = baseline.titleMedium.copy(fontFamily = displayFontFamily), 25 | titleSmall = baseline.titleSmall.copy(fontFamily = displayFontFamily), 26 | bodyLarge = baseline.bodyLarge.copy(fontFamily = bodyFontFamily), 27 | bodyMedium = baseline.bodyMedium.copy(fontFamily = bodyFontFamily), 28 | bodySmall = baseline.bodySmall.copy(fontFamily = bodyFontFamily), 29 | labelLarge = baseline.labelLarge.copy(fontFamily = bodyFontFamily), 30 | labelMedium = baseline.labelMedium.copy(fontFamily = bodyFontFamily), 31 | labelSmall = baseline.labelSmall.copy(fontFamily = bodyFontFamily), 32 | ) 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Light by Daniel Gamage 4 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax 5 | 6 | base: #fafafa 7 | mono-1: #383a42 8 | mono-2: #686b77 9 | mono-3: #a0a1a7 10 | hue-1: #0184bb 11 | hue-2: #4078f2 12 | hue-3: #a626a4 13 | hue-4: #50a14f 14 | hue-5: #e45649 15 | hue-5-2: #c91243 16 | hue-6: #986801 17 | hue-6-2: #c18401 18 | 19 | */ 20 | 21 | code { 22 | font-family: "JetBrains Mono", var(--mono-font) !important; 23 | } 24 | 25 | .hljs { 26 | color: #383a42; 27 | background: #fafafa; 28 | } 29 | 30 | .hljs-comment, 31 | .hljs-quote { 32 | color: #a0a1a7; 33 | font-style: italic; 34 | } 35 | 36 | .hljs-doctag, 37 | .hljs-keyword, 38 | .hljs-formula { 39 | color: #a626a4; 40 | } 41 | 42 | .hljs-section, 43 | .hljs-name, 44 | .hljs-selector-tag, 45 | .hljs-deletion, 46 | .hljs-subst { 47 | color: #e45649; 48 | } 49 | 50 | .hljs-literal { 51 | color: #0184bb; 52 | } 53 | 54 | .hljs-string, 55 | .hljs-regexp, 56 | .hljs-addition, 57 | .hljs-attribute, 58 | .hljs-meta .hljs-string { 59 | color: #50a14f; 60 | } 61 | 62 | .hljs-attr, 63 | .hljs-variable, 64 | .hljs-template-variable, 65 | .hljs-type, 66 | .hljs-selector-class, 67 | .hljs-selector-attr, 68 | .hljs-selector-pseudo, 69 | .hljs-number { 70 | color: #986801; 71 | } 72 | 73 | .hljs-symbol, 74 | .hljs-bullet, 75 | .hljs-link, 76 | .hljs-meta, 77 | .hljs-selector-id, 78 | .hljs-title { 79 | color: #4078f2; 80 | } 81 | 82 | .hljs-built_in, 83 | .hljs-title.class_, 84 | .hljs-class .hljs-title { 85 | color: #c18401; 86 | } 87 | 88 | .hljs-emphasis { 89 | font-style: italic; 90 | } 91 | 92 | .hljs-strong { 93 | font-weight: bold; 94 | } 95 | 96 | .hljs-link { 97 | text-decoration: underline; 98 | } -------------------------------------------------------------------------------- /theme/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Light by Daniel Gamage 4 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax 5 | 6 | base: #fafafa 7 | mono-1: #383a42 8 | mono-2: #686b77 9 | mono-3: #a0a1a7 10 | hue-1: #0184bb 11 | hue-2: #4078f2 12 | hue-3: #a626a4 13 | hue-4: #50a14f 14 | hue-5: #e45649 15 | hue-5-2: #c91243 16 | hue-6: #986801 17 | hue-6-2: #c18401 18 | 19 | */ 20 | 21 | code { 22 | font-family: "JetBrains Mono", var(--mono-font) !important; 23 | } 24 | 25 | .hljs { 26 | color: #383a42; 27 | background: #fafafa; 28 | } 29 | 30 | .hljs-comment, 31 | .hljs-quote { 32 | color: #a0a1a7; 33 | font-style: italic; 34 | } 35 | 36 | .hljs-doctag, 37 | .hljs-keyword, 38 | .hljs-formula { 39 | color: #a626a4; 40 | } 41 | 42 | .hljs-section, 43 | .hljs-name, 44 | .hljs-selector-tag, 45 | .hljs-deletion, 46 | .hljs-subst { 47 | color: #e45649; 48 | } 49 | 50 | .hljs-literal { 51 | color: #0184bb; 52 | } 53 | 54 | .hljs-string, 55 | .hljs-regexp, 56 | .hljs-addition, 57 | .hljs-attribute, 58 | .hljs-meta .hljs-string { 59 | color: #50a14f; 60 | } 61 | 62 | .hljs-attr, 63 | .hljs-variable, 64 | .hljs-template-variable, 65 | .hljs-type, 66 | .hljs-selector-class, 67 | .hljs-selector-attr, 68 | .hljs-selector-pseudo, 69 | .hljs-number { 70 | color: #986801; 71 | } 72 | 73 | .hljs-symbol, 74 | .hljs-bullet, 75 | .hljs-link, 76 | .hljs-meta, 77 | .hljs-selector-id, 78 | .hljs-title { 79 | color: #4078f2; 80 | } 81 | 82 | .hljs-built_in, 83 | .hljs-title.class_, 84 | .hljs-class .hljs-title { 85 | color: #c18401; 86 | } 87 | 88 | .hljs-emphasis { 89 | font-style: italic; 90 | } 91 | 92 | .hljs-strong { 93 | font-weight: bold; 94 | } 95 | 96 | .hljs-link { 97 | text-decoration: underline; 98 | } -------------------------------------------------------------------------------- /docs/theme/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Light by Daniel Gamage 4 | Original One Light Syntax theme from https://github.com/atom/one-light-syntax 5 | 6 | base: #fafafa 7 | mono-1: #383a42 8 | mono-2: #686b77 9 | mono-3: #a0a1a7 10 | hue-1: #0184bb 11 | hue-2: #4078f2 12 | hue-3: #a626a4 13 | hue-4: #50a14f 14 | hue-5: #e45649 15 | hue-5-2: #c91243 16 | hue-6: #986801 17 | hue-6-2: #c18401 18 | 19 | */ 20 | 21 | code { 22 | font-family: "JetBrains Mono", var(--mono-font) !important; 23 | } 24 | 25 | .hljs { 26 | color: #383a42; 27 | background: #fafafa; 28 | } 29 | 30 | .hljs-comment, 31 | .hljs-quote { 32 | color: #a0a1a7; 33 | font-style: italic; 34 | } 35 | 36 | .hljs-doctag, 37 | .hljs-keyword, 38 | .hljs-formula { 39 | color: #a626a4; 40 | } 41 | 42 | .hljs-section, 43 | .hljs-name, 44 | .hljs-selector-tag, 45 | .hljs-deletion, 46 | .hljs-subst { 47 | color: #e45649; 48 | } 49 | 50 | .hljs-literal { 51 | color: #0184bb; 52 | } 53 | 54 | .hljs-string, 55 | .hljs-regexp, 56 | .hljs-addition, 57 | .hljs-attribute, 58 | .hljs-meta .hljs-string { 59 | color: #50a14f; 60 | } 61 | 62 | .hljs-attr, 63 | .hljs-variable, 64 | .hljs-template-variable, 65 | .hljs-type, 66 | .hljs-selector-class, 67 | .hljs-selector-attr, 68 | .hljs-selector-pseudo, 69 | .hljs-number { 70 | color: #986801; 71 | } 72 | 73 | .hljs-symbol, 74 | .hljs-bullet, 75 | .hljs-link, 76 | .hljs-meta, 77 | .hljs-selector-id, 78 | .hljs-title { 79 | color: #4078f2; 80 | } 81 | 82 | .hljs-built_in, 83 | .hljs-title.class_, 84 | .hljs-class .hljs-title { 85 | color: #c18401; 86 | } 87 | 88 | .hljs-emphasis { 89 | font-style: italic; 90 | } 91 | 92 | .hljs-strong { 93 | font-weight: bold; 94 | } 95 | 96 | .hljs-link { 97 | text-decoration: underline; 98 | } -------------------------------------------------------------------------------- /src/tcg/api/ktor.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("PLUGIN_IS_NOT_ENABLED") 2 | package tcg.api 3 | 4 | import io.ktor.client.* 5 | import io.ktor.client.call.* 6 | import io.ktor.client.plugins.contentnegotiation.* 7 | import io.ktor.client.request.* 8 | import io.ktor.http.* 9 | import io.ktor.serialization.kotlinx.json.* 10 | import kotlinx.serialization.json.Json 11 | import tcg.* 12 | 13 | fun HttpClientWithJson(): HttpClient = HttpClient { 14 | install(ContentNegotiation) { 15 | json(Json { 16 | ignoreUnknownKeys = true 17 | }) 18 | } 19 | } 20 | 21 | class KtorPokemonTcgApi( 22 | private val httpClient: HttpClient = HttpClientWithJson() 23 | ): PokemonTcgApi { 24 | override suspend fun search(name: String): List { 25 | if (name.isBlank()) return emptyList() 26 | val response = httpClient.get("https://api.pokemontcg.io/v2/cards") { 27 | url { 28 | // bound the search to the newest regulation mark (do not show old cards) 29 | val regulationMark = CurrentRegulationMarks.joinToString(separator = " OR ") { "regulationMark:$it" } 30 | parameters.append("q", "name:\"*$name*\" ($regulationMark OR set.id:sve)") 31 | parameters.append("orderBy", "name") 32 | parameters.append("pageSize", "30") 33 | } 34 | } 35 | if (response.status != HttpStatusCode.OK) return emptyList() 36 | return response.body().data.map { it.tcg } 37 | } 38 | 39 | override suspend fun getById(identifier: String): Card? { 40 | val response = httpClient.get("https://api.pokemontcg.io/v2/cards/$identifier") 41 | if (response.status != HttpStatusCode.OK) return null 42 | return response.body().data.tcg 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/tcg/api/local.kt: -------------------------------------------------------------------------------- 1 | package tcg.api 2 | 3 | import kotlinx.serialization.ExperimentalSerializationApi 4 | import kotlinx.serialization.json.Json 5 | import kotlinx.serialization.json.decodeFromStream 6 | import tcg.Card 7 | import kotlin.io.path.Path 8 | import kotlin.io.path.inputStream 9 | import kotlin.io.path.listDirectoryEntries 10 | 11 | const val LOCAL_DATA_FOLDER = "pokemon-tcg-data/cards/en" 12 | const val LOCAL_DATA_GLOB = "*.json" 13 | 14 | class LocalPokemonTcgApi(): PokemonTcgApi{ 15 | val json = Json { ignoreUnknownKeys = true } 16 | 17 | @OptIn(ExperimentalSerializationApi::class) 18 | inline fun forEachCard(block: (Card) -> Unit) { 19 | val localDataFolder = Path(LOCAL_DATA_FOLDER) 20 | for (file in localDataFolder.listDirectoryEntries(LOCAL_DATA_GLOB)) { 21 | file.inputStream().use { stream -> 22 | try { 23 | json.decodeFromStream>(stream).forEach { 24 | block(it.tcg) 25 | } 26 | } catch (e: Exception) { 27 | println("Failed to parse $file") 28 | e.printStackTrace() 29 | } 30 | } 31 | } 32 | } 33 | 34 | fun Card.inFormat(): Boolean = 35 | regulationMark in CurrentRegulationMarks || identifier.startsWith("sve-") 36 | 37 | override suspend fun search(name: String): List = buildList { 38 | forEachCard { card -> 39 | if (card.inFormat() && card.name.contains(name, ignoreCase = true)) 40 | add(card) 41 | } 42 | } 43 | 44 | override suspend fun getById(identifier: String): Card? { 45 | forEachCard { card -> 46 | if (card.identifier == identifier) return@getById card 47 | } 48 | return null 49 | } 50 | } -------------------------------------------------------------------------------- /module.yaml: -------------------------------------------------------------------------------- 1 | product: jvm/app 2 | 3 | dependencies: 4 | - org.slf4j:slf4j-simple:2.0.17 5 | - org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2 6 | - $kotlin.serialization.json 7 | - $compose.foundation 8 | - $compose.components.resources 9 | - $compose.desktop.currentOs 10 | - $compose.desktop.components.splitPane 11 | - org.jetbrains.compose.material3:material3:1.10.0-alpha04 12 | - org.jetbrains.compose.material:material-icons-extended:1.7.3 13 | - org.jetbrains.androidx.navigation3:navigation3-ui:1.0.0-alpha05 14 | - org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0-alpha05 15 | - org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0-alpha05 16 | - io.coil-kt.coil3:coil-compose:3.3.0 17 | - io.coil-kt.coil3:coil-network-ktor3:3.3.0 18 | - io.github.vinceglb:filekit-compose:0.8.8 19 | - io.arrow-kt:arrow-core:2.2.1.1 20 | - io.arrow-kt:arrow-fx-coroutines:2.2.1.1 21 | - io.arrow-kt:arrow-resilience:2.2.1.1 22 | - io.arrow-kt:arrow-optics:2.2.1.1 23 | - io.arrow-kt:suspendapp:2.2.1.1 24 | - io.github.nomisrev:kotlinx-serialization-jsonpath:1.0.0 25 | - io.github.reactivecircus.cache4k:cache4k:0.14.0 26 | - $ktor.client 27 | - $ktor.client.cio 28 | - $ktor.client.contentNegotiation 29 | - $ktor.serialization.kotlinx.json 30 | 31 | settings: 32 | kotlin: 33 | version: 2.3.0 34 | serialization: 35 | format: json 36 | version: 1.9.0 37 | freeCompilerArgs: 38 | - "-Xreturn-value-checker=check" 39 | ksp: 40 | processors: 41 | - io.arrow-kt:arrow-optics-ksp-plugin:2.2.1.1 42 | compose: 43 | enabled: true 44 | version: 1.10.0-rc02 45 | resources: 46 | packageName: resources 47 | ktor: 48 | enabled: true 49 | version: 3.3.3 -------------------------------------------------------------------------------- /docs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/tcg/cardView.kt: -------------------------------------------------------------------------------- 1 | package tcg 2 | 3 | import androidx.compose.foundation.* 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.Text 6 | import androidx.compose.material3.Card as Material3Card 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.style.TextAlign 11 | import androidx.compose.ui.text.style.TextOverflow 12 | import androidx.compose.ui.unit.dp 13 | 14 | @OptIn(ExperimentalLayoutApi::class) 15 | @Composable 16 | fun MultipleCards( 17 | cards: List, 18 | modifier: Modifier = Modifier, 19 | extra: @Composable ColumnScope.(Card) -> Unit = { } 20 | ) { 21 | Box(modifier) { 22 | val scrollState = rememberScrollState() 23 | FlowRow( 24 | modifier = Modifier.fillMaxSize().verticalScroll(scrollState) 25 | ) { 26 | for (card in cards) { 27 | SingleCard(card, extra = extra) 28 | } 29 | } 30 | VerticalScrollbar( 31 | modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), 32 | adapter = rememberScrollbarAdapter(scrollState) 33 | ) 34 | } 35 | } 36 | 37 | @Composable 38 | fun SingleCard( 39 | card: Card, 40 | modifier: Modifier = Modifier, 41 | extra: @Composable ColumnScope.(Card) -> Unit = { } 42 | ) { 43 | Material3Card(modifier = modifier.width(150.dp).padding(5.dp)) { 44 | Column { 45 | Text( 46 | card.name, 47 | textAlign = TextAlign.Center, 48 | maxLines = 1, 49 | overflow = TextOverflow.Ellipsis, 50 | modifier = Modifier.fillMaxWidth().padding(2.dp) 51 | ) 52 | Image( 53 | painter = card.imageResource, 54 | contentDescription = card.identifier, 55 | modifier = Modifier.padding(5.dp) 56 | ) 57 | extra(card) 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/search/viewModel.kt: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import androidx.compose.runtime.getValue 4 | import androidx.compose.runtime.mutableStateOf 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.viewModelScope 7 | import arrow.core.Either 8 | import kotlin.time.Duration.Companion.milliseconds 9 | import kotlinx.coroutines.Job 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.launch 12 | import tcg.Card 13 | import tcg.api.KtorPokemonTcgApi 14 | import tcg.api.LocalPokemonTcgApi 15 | import tcg.api.PokemonTcgApi 16 | 17 | sealed interface SearchStatus { 18 | data class Loading(val job: Job) : SearchStatus 19 | data class Ok(val results: List) : SearchStatus { 20 | val isEmpty: Boolean = results.isEmpty() 21 | } 22 | data object Error : SearchStatus 23 | } 24 | 25 | class SearchViewModel( 26 | private val api: PokemonTcgApi = LocalPokemonTcgApi() // KtorPokemonTcgApi() 27 | ) : ViewModel() { 28 | private val _options = mutableStateOf(SearchOptions.INITIAL) 29 | val options: SearchOptions by _options 30 | 31 | private val _result = mutableStateOf(SearchStatus.Ok(emptyList())) 32 | val result: SearchStatus by _result 33 | 34 | fun updateText(newText: String) { 35 | _options.value = _options.value.copy(text = newText) 36 | 37 | // cancel previous job if loading 38 | (_result.value as? SearchStatus.Loading)?.job?.cancel() 39 | // now start the new job 40 | _result.value = SearchStatus.Loading( 41 | viewModelScope.launch { 42 | // give time for the previous job to cancel 43 | delay(500.milliseconds) 44 | Either.catch { api.search(newText) } 45 | .fold( 46 | ifLeft = { _result.value = SearchStatus.Error }, 47 | ifRight = { _result.value = SearchStatus.Ok(it) } 48 | ) 49 | } 50 | ) 51 | } 52 | } 53 | 54 | data class SearchOptions(val text: String) { 55 | companion object { 56 | val INITIAL: SearchOptions = SearchOptions("") 57 | } 58 | } -------------------------------------------------------------------------------- /src/main.kt: -------------------------------------------------------------------------------- 1 | import androidx.compose.foundation.background 2 | import androidx.compose.foundation.layout.fillMaxSize 3 | import androidx.compose.foundation.layout.padding 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.unit.dp 7 | import androidx.compose.ui.window.Window 8 | import androidx.compose.ui.window.application 9 | import androidx.lifecycle.viewmodel.compose.viewModel 10 | import deck.DeckPane 11 | import deck.DeckViewModel 12 | import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi 13 | import org.jetbrains.compose.splitpane.HorizontalSplitPane 14 | import search.SearchPane 15 | import theme.AppTheme 16 | import utils.HorizontalSplitPaneSplitter 17 | import java.lang.System.setProperty 18 | 19 | @OptIn(ExperimentalSplitPaneApi::class) 20 | fun main() { 21 | // set application title 22 | // https://stackoverflow.com/questions/78097759/how-can-i-change-the-app-name-with-compose-multiplatform-in-macos 23 | setProperty("apple.awt.application.name", "Poké-Fun") 24 | // start the application proper 25 | application { 26 | AppTheme { 27 | Window( 28 | title = "Poké-Fun", 29 | onCloseRequest = ::exitApplication 30 | ) { 31 | val sharedDeckModel = viewModel { DeckViewModel() } 32 | 33 | HorizontalSplitPane( 34 | modifier = Modifier.background(MaterialTheme.colorScheme.background) 35 | ) { 36 | first(320.dp) { 37 | SearchPane(sharedDeckModel, modifier = Modifier.padding(10.dp).fillMaxSize()) 38 | } 39 | second { 40 | DeckPane(sharedDeckModel, modifier = Modifier.fillMaxSize()) 41 | // use this to introduce navigation 42 | // DeckPaneWithDetails(sharedDeckModel, modifier = Modifier.fillMaxSize()) 43 | } 44 | splitter { 45 | HorizontalSplitPaneSplitter() 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/tomorrow-night.css: -------------------------------------------------------------------------------- 1 | /* Tomorrow Night Theme */ 2 | /* https://github.com/jmblog/color-themes-for-highlightjs */ 3 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */ 4 | /* https://github.com/jmblog/color-themes-for-highlightjs */ 5 | 6 | /* Tomorrow Comment */ 7 | .hljs-comment { 8 | color: #969896; 9 | } 10 | 11 | /* Tomorrow Red */ 12 | .hljs-variable, 13 | .hljs-attribute, 14 | .hljs-attr, 15 | .hljs-tag, 16 | .hljs-regexp, 17 | .ruby .hljs-constant, 18 | .xml .hljs-tag .hljs-title, 19 | .xml .hljs-pi, 20 | .xml .hljs-doctype, 21 | .html .hljs-doctype, 22 | .css .hljs-id, 23 | .css .hljs-class, 24 | .css .hljs-pseudo { 25 | color: #cc6666; 26 | } 27 | 28 | /* Tomorrow Orange */ 29 | .hljs-number, 30 | .hljs-preprocessor, 31 | .hljs-pragma, 32 | .hljs-built_in, 33 | .hljs-literal, 34 | .hljs-params, 35 | .hljs-constant { 36 | color: #de935f; 37 | } 38 | 39 | /* Tomorrow Yellow */ 40 | .ruby .hljs-class .hljs-title, 41 | .css .hljs-rule .hljs-attribute { 42 | color: #f0c674; 43 | } 44 | 45 | /* Tomorrow Green */ 46 | .hljs-string, 47 | .hljs-value, 48 | .hljs-inheritance, 49 | .hljs-header, 50 | .hljs-name, 51 | .ruby .hljs-symbol, 52 | .xml .hljs-cdata { 53 | color: #b5bd68; 54 | } 55 | 56 | /* Tomorrow Aqua */ 57 | .hljs-title, 58 | .hljs-section, 59 | .css .hljs-hexcolor { 60 | color: #8abeb7; 61 | } 62 | 63 | /* Tomorrow Blue */ 64 | .hljs-function, 65 | .python .hljs-decorator, 66 | .python .hljs-title, 67 | .ruby .hljs-function .hljs-title, 68 | .ruby .hljs-title .hljs-keyword, 69 | .perl .hljs-sub, 70 | .javascript .hljs-title, 71 | .coffeescript .hljs-title { 72 | color: #81a2be; 73 | } 74 | 75 | /* Tomorrow Purple */ 76 | .hljs-keyword, 77 | .javascript .hljs-function { 78 | color: #b294bb; 79 | } 80 | 81 | .hljs { 82 | display: block; 83 | overflow-x: auto; 84 | background: #1d1f21; 85 | color: #c5c8c6; 86 | } 87 | 88 | .coffeescript .javascript, 89 | .javascript .xml, 90 | .tex .hljs-formula, 91 | .xml .javascript, 92 | .xml .vbscript, 93 | .xml .css, 94 | .xml .hljs-cdata { 95 | opacity: 0.5; 96 | } 97 | 98 | .hljs-addition { 99 | color: #718c00; 100 | } 101 | 102 | .hljs-deletion { 103 | color: #c82829; 104 | } 105 | -------------------------------------------------------------------------------- /src/search/view.kt: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.material.icons.Icons 5 | import androidx.compose.material.icons.filled.Add 6 | import androidx.compose.material.icons.filled.Search 7 | import androidx.compose.material3.* 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import androidx.compose.ui.unit.sp 12 | import androidx.lifecycle.viewmodel.compose.viewModel 13 | import deck.DeckViewModel 14 | import tcg.MultipleCards 15 | 16 | @Composable 17 | fun SearchPane( 18 | deck: DeckViewModel, 19 | search: SearchViewModel = viewModel { SearchViewModel() }, 20 | modifier: Modifier = Modifier 21 | ) { 22 | Box(modifier) { 23 | Column { 24 | TextField( 25 | value = search.options.text, 26 | onValueChange = search::updateText, 27 | label = { Text("Card name", fontSize = 10.sp) }, 28 | leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search by card name") }, 29 | modifier = Modifier.fillMaxWidth() 30 | ) 31 | when (val result = search.result) { 32 | is SearchStatus.Loading -> 33 | CircularProgressIndicator( 34 | modifier = Modifier.height(20.dp).padding(10.dp) 35 | ) 36 | is SearchStatus.Error -> 37 | Text( 38 | "Problems during search", 39 | color = MaterialTheme.colorScheme.error, 40 | modifier = Modifier.padding(10.dp) 41 | ) 42 | is SearchStatus.Ok -> when { 43 | result.isEmpty -> Text( 44 | "No match found", 45 | color = MaterialTheme.colorScheme.primary, 46 | modifier = Modifier.padding(10.dp) 47 | ) 48 | else -> MultipleCards( 49 | cards = result.results, 50 | modifier = Modifier.fillMaxSize() 51 | ) { card -> 52 | TextButton( 53 | onClick = { deck.add(card) }, 54 | modifier = Modifier.fillMaxWidth() 55 | ) { 56 | Icon(Icons.Filled.Add, contentDescription = "Add ${card.name}") 57 | Text("Add", color = MaterialTheme.colorScheme.primary) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/tcg/api/common.kt: -------------------------------------------------------------------------------- 1 | package tcg.api 2 | 3 | import kotlinx.serialization.Serializable 4 | import tcg.* 5 | 6 | val CurrentRegulationMarks = listOf("G", "H", "I") 7 | 8 | @Serializable 9 | data class JsonCard( 10 | val name: String, 11 | val id: String, 12 | val supertype: String, 13 | val subtypes: List = emptyList(), 14 | val types: List = emptyList(), 15 | val regulationMark: String? = null, 16 | ) { 17 | val basicSubtypes = listOf("Basic", "Baby", "Restores", "V") 18 | val level1Subtypes = listOf("Stage 1", "V") 19 | val level2Subtypes = listOf("Stage 2", "VMAX", "VSTAR", "Restored") 20 | val otherSubtypes = listOf("Level-Up", "BREAK", "LEGEND", "V-UNION") 21 | 22 | val tcg: Card 23 | get() { 24 | val category = when (supertype) { 25 | "Pokémon" -> when { 26 | "EX" in subtypes && "MEGA" in subtypes -> Category.Pokemon(PokemonStage.Stage1) 27 | "EX" in subtypes -> Category.Pokemon(PokemonStage.Basic) 28 | subtypes.any { it in otherSubtypes } -> Category.Pokemon(PokemonStage.Other) 29 | subtypes.any { it in level2Subtypes } -> Category.Pokemon(PokemonStage.Stage2) 30 | subtypes.any { it in level1Subtypes } -> Category.Pokemon(PokemonStage.Stage1) 31 | subtypes.any { it in basicSubtypes } -> Category.Pokemon(PokemonStage.Basic) 32 | else -> throw IllegalArgumentException("Pokémon $subtypes not recognized") 33 | } 34 | "Energy" -> when { 35 | "Basic" in subtypes || subtypes.isEmpty() -> Category.Energy(EnergyCategory.Basic) 36 | "Special" in subtypes -> Category.Energy(EnergyCategory.Special) 37 | else -> throw IllegalArgumentException("Energy $subtypes not recognized") 38 | } 39 | "Trainer" -> when { 40 | "Item" in subtypes || subtypes.isEmpty() -> Category.Trainer(TrainerCategory.Item) 41 | "Pokémon Tool" in subtypes -> Category.Trainer(TrainerCategory.Tool) 42 | "Stadium" in subtypes -> Category.Trainer(TrainerCategory.Stadium) 43 | "Supporter" in subtypes -> Category.Trainer(TrainerCategory.Supporter) 44 | else -> Category.Trainer(TrainerCategory.Other) 45 | } 46 | else -> throw IllegalArgumentException() 47 | } 48 | val type = types.firstOrNull()?.let(Type::valueOf) 49 | return Card(name, id, category, type, regulationMark) 50 | } 51 | } 52 | 53 | @Serializable 54 | data class JsonSingleResult( 55 | val data: JsonCard 56 | ) 57 | 58 | @Serializable 59 | data class JsonMultipleResult( 60 | val data: List 61 | ) 62 | 63 | 64 | -------------------------------------------------------------------------------- /guide/intro.md: -------------------------------------------------------------------------------- 1 | # Overview of the code 2 | 3 | Now that you know about the [domain](./tcg.md) and the [technology](./tech-intro.md), we can describe the given implementation of Poké-Fun. 4 | 5 | ## Cards 6 | 7 | The `tcg` module gives access to cards and their information. 8 | 9 | - 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_](./adt.md) section. 10 | - Basic validation is implemented in `validation.kt`. In the [_Validation_](./validation.md) section we'll improve this functionality. 11 | 12 | ## General design 13 | 14 | The diagram below roughly represents how Poké-Fun is architected. 15 | 16 | ```mermaid 17 | graph LR; 18 | PokemonTcgApi; 19 | SearchViewModel; 20 | PokemonTcgApi <-.- SearchViewModel; 21 | DeckViewModel; 22 | subgraph View [SplitPane] 23 | SearchPane; 24 | DeckPane; 25 | end 26 | SearchViewModel --- SearchPane; 27 | DeckViewModel --- SearchPane; 28 | DeckViewModel --- DeckPane; 29 | ``` 30 | 31 | In the center we find two different view models, which serve different purposes: 32 | 33 | - `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. 34 | - `SearchViewModel` (in `search/viewModel.kt`) keeps track of the state of search, and is responsible for communicating with the [Pokémon TCG API](https://docs.pokemontcg.io/). 35 | 36 | 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](https://ktor.io/docs/client-create-new-application.html), and a "fake" one with a few predefined cards. After finishing the [_Deal with bad internet_](./resilience.md) section, we'll have some respectable code. 37 | 38 | 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](https://github.com/JetBrains/compose-multiplatform/blob/master/tutorials/README.md#desktop) offered by Compose Multiplatform. 39 | 40 | - 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`. 41 | - On the right-hand side we have the `DeckPane` (in `deck/view.kt`), which simply shows the cards and problems. 42 | 43 | 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). 44 | -------------------------------------------------------------------------------- /guide/welcome.md: -------------------------------------------------------------------------------- 1 | # Poké-Fun with Kotlin and Arrow 2 | 3 | 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. 4 | 5 | > The source code is available in [this repository](https://github.com/serras/poke-fun). Download or clone it, and you should be ready to go. 6 | 7 | This book assumes that you know your way around [Kotlin](https://kotlinlang.org), but previous experience with functional programming or [Arrow](https://arrow-kt.io), or with Compose Multiplatform, is not required. 8 | 9 | ```admonish note title="Compose is multi-plaftorm" 10 | 11 | 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. 12 | 13 | ``` 14 | 15 | The starting point introduces the domain and the main components of the technology. 16 | 17 | - 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](./tcg.md); 18 | - If you are new to Amper or Compose Multiplatform, start with [the technology](./tech-intro.md), 19 | 20 | ```admonish warning title="Built with Amper" 21 | 22 | Poké-Fun uses [Amper](https://github.com/JetBrains/amper) as build tool, as opposed to the most usual Gradle. In particular, you need to install the [corresponding plug-in](https://plugins.jetbrains.com/plugin/23076-amper) if you are using IntelliJ or Android Studio. 23 | 24 | ``` 25 | 26 | Afterward, the [overview](./intro.md) describes the main components of the given code. 27 | 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. 28 | 29 | - [What is (in) a deck](./adt.md): model data using data classes and sealed hierarchies; 30 | - [Law-abiding decks](./validation.md): check that the deck follows the rules, and learn about `Raise` along the way; 31 | - [Deck building](./build.md): design a good `ViewModel` using functional principles, and design undo/redo with actions-as-data; 32 | - [Deal with bad internet](./resilience.md): improve the experience with `Schedule` and `CircuitBreaker`, and cache results using memoization; 33 | - [Using local data](./local.md): use local storage for card data, using optics to query it; 34 | - [Loading and saving](./par.md): store your work locally, and learn about parallel combinators in Arrow Fx; 35 | - [Better architecture](./architecture.md): introduce resource management, and overall nicer design; 36 | - [Nicer UI](./cmp.md): implement more visual feedback using Compose Multiplatform. 37 | 38 | ```admonish tip title="A word from our sponsor" 39 | 40 | Many of these sections complement the book [Functional Programming Ideas for the Curious Kotliner](https://leanpub.com/fp-ideas-kotlin). 41 | 42 | ``` -------------------------------------------------------------------------------- /guide/cmp.md: -------------------------------------------------------------------------------- 1 | # Nicer UI 2 | 3 | > **Topics**: Compose, navigation 4 | 5 | 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. 6 | 7 | ## Better search 8 | 9 | Right now Poké-Fun only searches cards by name. However, the API has [many more options](https://docs.pokemontcg.io/api-reference/cards/search-cards/), 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. 10 | 11 | Your **task** is to provide an _advanced_ search view (you can look at the [official card database](https://www.pokemon.com/us/pokemon-tcg/pokemon-cards/) for inspiration). This requires changes in a few places: 12 | 13 | - You have to extend `search` method in the `PokemonTcgApi` interface to take the additional information as input. 14 | - You have to modify the Ktor implementation to generate the correct query string. 15 | - You have to provide additional UI elements for the player to change the filters. 16 | 17 | 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. 18 | 19 | ## Card detail view 20 | 21 | 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. 22 | 23 | We encourage you to use the [navigation library](https://developer.android.com/guide/navigation/navigation-3) 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. 24 | 25 | ```kotlin 26 | entry { 27 | DeckPane(deck, modifier) 28 | } 29 | entry { 30 | // get the cardId from the argument 31 | val cardId = it.cardId 32 | // build the UI from this information 33 | Text("Card with id $cardId") 34 | } 35 | ``` 36 | 37 | ```admonish info title="Type safe routes" 38 | 39 | The Compose Navigation3 library uses serializable objects to define values. 40 | In the previous iteration of this navigation, prior to 2.8.0, you used strings instead. 41 | One more instance where [type safety](https://developer.android.com/guide/navigation/design/type-safety) is a welcome addition. 42 | 43 | ``` 44 | 45 | ## Debouncing 46 | 47 | 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. 48 | 49 | Your **task** is to apply that technique to Poké-Fun. There are several options to do so, [this guide](https://xinkev.com/note/androiddev/debouncing-textfields-in-compose/) discusses the most straightforward ones in the context of Compose. 50 | -------------------------------------------------------------------------------- /guide/architecture.md: -------------------------------------------------------------------------------- 1 | # Better architecture 2 | 3 | > **Topics**: resource management, `SuspendApp` 4 | 5 | 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. 6 | 7 | ```admonish quote title="Scouts rule" 8 | 9 | _Leave something slightly better of than you found it._ 10 | 11 | ``` 12 | 13 | ## As explicit as possible 14 | 15 | 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: 16 | 17 | 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_](https://github.com/Kotlin/KEEP/blob/context-parameters/proposals/context-parameters.md)). 18 | 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`. 19 | 20 | A longer explanation, including more examples of the usage of receivers, can be found in the [Arrow documentation](https://arrow-kt.io/learn/design/effects-contexts/). 21 | 22 | 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](https://insert-koin.io/) and [Hilt](https://developer.android.com/training/dependency-injection/hilt-android). 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. 23 | 24 | ## Resource management 25 | 26 | 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, 27 | 28 | ```kotlin 29 | HttpClient().use { client -> 30 | KtorPokemonTcgApi(client).use { api -> 31 | // now start the application 32 | } 33 | } 34 | ``` 35 | 36 | Arrow solves this problem with [`resourceScope` blocks](https://arrow-kt.io/learn/coroutines/resource-safety/). The code above can be rewritten with any nesting, given that they implement [`AutoCloseable`](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-auto-closeable/). 37 | 38 | ```kotlin 39 | resourceScope { 40 | val client = autoCloseable { HttpClient() } 41 | val api = autoCloseable { KtorPokemonTcgApi(client) } 42 | // now start the application 43 | } 44 | ``` 45 | 46 | One step further is [SuspendApp](https://arrow-kt.io/ecosystem/suspendapp/), which adds graceful shutdown to the whole application. By combining [SuspendApp with Resource](https://arrow-kt.io/ecosystem/suspendapp/#suspendapp-arrows-resource), you can ensure that finalizers runs correctly, even when the application is terminated. 47 | 48 | ### Poké-Resources 49 | 50 | 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. -------------------------------------------------------------------------------- /docs/toc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
  1. Welcome
  2. Introduction
  3. Trading Card Games
  4. The technology
  5. Overview of the code
  6. Practice
  7. What is (in) a deck
  8. Law-abiding decks
  9. Deck building
  10. Deal with bad internet
  11. Using local data
  12. Loading and saving
  13. Better architecture
  14. Nicer UI
33 | 34 | 35 | -------------------------------------------------------------------------------- /guide/tcg.md: -------------------------------------------------------------------------------- 1 | # Trading Card Games 2 | 3 | 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_. 4 | 5 | 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. 6 | 7 | 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. 8 | 9 | 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. 10 | 11 | | | | 12 | |---|---| 13 | | ![Bulbasaur](https://images.pokemontcg.io/svp/46_hires.png) | _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. | 14 | | ![Grass Energy](https://images.pokemontcg.io/sve/1_hires.png) | _Energy_ cards are needed to power the attacks of Pokémon. | 15 | | ![Great Ball](https://images.pokemontcg.io/sv2/183_hires.png) | _Trainer_ cards provide additional effects that you can use to help in the battle. | 16 | 17 | 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. 18 | 19 | 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_. 20 | 21 | 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. 22 | 23 | ```admonish example 24 | 25 | These are two different (happy) Oddish cards. Same name, different identifier, different attacks and HP. 26 | 27 | ![Oddish 1](https://images.pokemontcg.io/sv3/1.png) ![Oddish 2](https://images.pokemontcg.io/sv3pt5/43.png) 28 | 29 | ``` 30 | 31 | This is enough for our purposes. If you are interested to learn how to play the game, check the [official rulebook](https://www.pokemon.com/us/pokemon-tcg/rules). 32 | -------------------------------------------------------------------------------- /guide/par.md: -------------------------------------------------------------------------------- 1 | # Loading and saving 2 | 3 | > **Topics**: parallel operations, `Raise` + exceptions + concurrency 4 | 5 | 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. 6 | 7 | 8 | 9 | ## Load and store 10 | 11 | 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. 12 | 13 | The code provided in `deck/view.kt` integrates [FileKit](https://github.com/vinceglb/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`. 14 | 15 | 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. 16 | 17 | 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_](https://arrow-kt.io/learn/coroutines/parallel/) which solves the problem quite succintly. Use [`parMap`](https://arrow-kt.io/learn/coroutines/parallel/#independently-in-parallel) to turn the sequential iteration into a concurrent set of operations. 18 | 19 | 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](./resilience.md#introduce-a-cache) that works independently of the number of consumers. 20 | 21 | ## From exceptions to `Raise` 22 | 23 | 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`. 24 | 25 | ```admonish info title="About Raise" 26 | 27 | It is strongly recommended to read the [_Law-abiding decks_](./validation.md) section, which introduces the basics of `Raise`, before attempting the following task. 28 | 29 | The integration of `parMap` (and `parZip`) with `Raise` and error accumulation is discussed in the [Arrow documentation](https://arrow-kt.io/learn/coroutines/parallel/#integration-with-typed-errors). Although the TL;DR is simply "replace `mapOrAccumulate` with `parMapOrAccumulate` and enjoy". 30 | 31 | ``` 32 | 33 | Your **task** is to use [`Either.catch`](https://arrow-kt.io/learn/typed-errors/working-with-typed-errors/#from-exceptions) to capture any potential exceptions, and transform then into `Either`. As hinted in the [_Law-abiding decks_](./validation.md) section, you need to define an error hierarchy to represent those problems. 34 | 35 | ```admonish tip title="Several error hierarchies" 36 | 37 | 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. 38 | 39 | ``` 40 | 41 | 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. 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/tcg/tcg.kt: -------------------------------------------------------------------------------- 1 | package tcg 2 | 3 | import androidx.compose.runtime.Composable 4 | import coil3.compose.AsyncImagePainter 5 | import coil3.compose.rememberAsyncImagePainter 6 | 7 | data class Deck( 8 | val title: String, 9 | val cards: List 10 | ) { 11 | companion object { 12 | val INITIAL: Deck = Deck("Awesome Deck", emptyList()) 13 | } 14 | } 15 | 16 | data class Card( 17 | val name: String, 18 | val identifier: String, 19 | val category: Category, 20 | val type: Type?, 21 | val regulationMark: String?, 22 | ): Comparable { 23 | val imageUrl: String 24 | get() { 25 | val (set, id) = identifier.split('-') 26 | return "https://images.pokemontcg.io/$set/${id}_hires.png" 27 | } 28 | 29 | val imageResource: AsyncImagePainter 30 | @Composable get() = rememberAsyncImagePainter(imageUrl) 31 | 32 | override fun compareTo(other: Card): Int { 33 | if (this.category != other.category) return this.category.compareTo(other.category) 34 | if (this.name != other.name) return this.name.compareTo(other.name) 35 | return this.identifier.compareTo(other.identifier) 36 | } 37 | } 38 | 39 | sealed interface Category: Comparable { 40 | data class Pokemon(val stage: PokemonStage) : Category 41 | data class Energy(val category: EnergyCategory) : Category 42 | data class Trainer(val category: TrainerCategory) : Category 43 | 44 | override fun compareTo(other: Category): Int = 45 | when { 46 | this is Pokemon && other is Pokemon -> 47 | this.stage.compareTo(other.stage) 48 | this is Pokemon -> -1 49 | other is Pokemon -> 1 50 | this is Energy && other is Energy -> 51 | this.category.compareTo(other.category) 52 | this is Energy -> -1 53 | other is Energy -> 1 54 | this is Trainer && other is Trainer -> 55 | this.category.compareTo(other.category) 56 | this is Trainer -> -1 57 | other is Trainer -> 1 58 | else -> 0 59 | } 60 | } 61 | 62 | enum class PokemonStage { 63 | Basic, 64 | Stage1, 65 | Stage2, 66 | Other; 67 | } 68 | 69 | enum class EnergyCategory { 70 | Basic, 71 | Special; 72 | } 73 | 74 | enum class TrainerCategory { 75 | Item, 76 | Tool, 77 | Supporter, 78 | Stadium, 79 | Other; 80 | } 81 | 82 | enum class Type(private val imageUrl: String) { 83 | Colorless("https://archives.bulbagarden.net/media/upload/thumb/1/1d/Colorless-attack.png/40px-Colorless-attack.png"), 84 | Grass("https://archives.bulbagarden.net/media/upload/thumb/2/2e/Grass-attack.png/40px-Grass-attack.png"), 85 | Water("https://archives.bulbagarden.net/media/upload/thumb/1/11/Water-attack.png/40px-Water-attack.png"), 86 | Fire("https://archives.bulbagarden.net/media/upload/thumb/a/ad/Fire-attack.png/40px-Fire-attack.png"), 87 | Lightning("https://archives.bulbagarden.net/media/upload/thumb/0/04/Lightning-attack.png/40px-Lightning-attack.png"), 88 | Fighting("https://archives.bulbagarden.net/media/upload/thumb/4/48/Fighting-attack.png/40px-Fighting-attack.png"), 89 | Psychic("https://archives.bulbagarden.net/media/upload/thumb/e/ef/Psychic-attack.png/40px-Psychic-attack.png"), 90 | Darkness("https://archives.bulbagarden.net/media/upload/thumb/a/ab/Darkness-attack.png/40px-Darkness-attack.png"), 91 | Metal("https://archives.bulbagarden.net/media/upload/thumb/6/64/Metal-attack.png/40px-Metal-attack.png"), 92 | Dragon("https://archives.bulbagarden.net/media/upload/thumb/8/8a/Dragon-attack.png/40px-Dragon-attack.png"), 93 | Fairy("https://archives.bulbagarden.net/media/upload/thumb/4/40/Fairy-attack.png/40px-Fairy-attack.png"); 94 | 95 | val imageResource: AsyncImagePainter 96 | @Composable get() = rememberAsyncImagePainter(imageUrl) 97 | } -------------------------------------------------------------------------------- /guide/validation.md: -------------------------------------------------------------------------------- 1 | # Law-abiding decks 2 | 3 | > **Topics**: validation, `Raise`, error accumulation 4 | 5 | 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. 6 | 7 | This topic is well documented in the official Arrow documentation, we suggest the reader to check the following material: 8 | 9 | - [Working with typed errors](https://arrow-kt.io/learn/typed-errors/working-with-typed-errors/) 10 | - [Validation](https://arrow-kt.io/learn/typed-errors/validation/) 11 | 12 | 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. 13 | 14 | ## Legal decks 15 | 16 | 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. 17 | 18 | The main rules for the legality of a deck are: 19 | 20 | - The deck must contain exactly 60 cards, 21 | - There must be at most 4 cards with the same name, 22 | - Note that cards with different identifiers but the same name are added together, 23 | - The only exception to this rule are _basic_ Energy cards, of which you can have an unlimited amount, 24 | - There must be at least one _basic_ Pokémon. 25 | 26 | Implement this validation using `Either` or `Raise`, and try to break the process in different functions. The notion of [fail-first vs. accumulation](https://arrow-kt.io/learn/typed-errors/validation/#fail-first-vs-accumulation) is important here, so you can squeeze as much information as possible. 27 | 28 | **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. 29 | 30 | ## Problems tied to specific cards 31 | 32 | 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. 33 | 34 | **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. 35 | 36 | ## Reactive problems 37 | 38 | 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](https://github.com/ReactiveX/RxJava) allow expressing this connection directly, and we can easily do the same using `MutableState`. 39 | 40 | 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`. 41 | 42 | ```admonish info title="Map as in lists" 43 | 44 | 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. 45 | 46 | ``` 47 | 48 | ## Gym Leader Challenge 49 | 50 | 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](https://gymleaderchallenge.com/) (GLC). As an **extra task**, you may implement [GLC rules](https://gymleaderchallenge.com/rules). 51 | 52 | - You may need to add some UI element to specify the format your deck is in. 53 | - 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. 54 | -------------------------------------------------------------------------------- /docs/fonts/fonts.css: -------------------------------------------------------------------------------- 1 | /* Open Sans is licensed under the Apache License, Version 2.0. See http://www.apache.org/licenses/LICENSE-2.0 */ 2 | /* Source Code Pro is under the Open Font License. See https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL */ 3 | 4 | /* open-sans-300 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 5 | @font-face { 6 | font-family: 'Open Sans'; 7 | font-style: normal; 8 | font-weight: 300; 9 | src: local('Open Sans Light'), local('OpenSans-Light'), 10 | url('../fonts/open-sans-v17-all-charsets-300.woff2') format('woff2'); 11 | } 12 | 13 | /* open-sans-300italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 14 | @font-face { 15 | font-family: 'Open Sans'; 16 | font-style: italic; 17 | font-weight: 300; 18 | src: local('Open Sans Light Italic'), local('OpenSans-LightItalic'), 19 | url('../fonts/open-sans-v17-all-charsets-300italic.woff2') format('woff2'); 20 | } 21 | 22 | /* open-sans-regular - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 23 | @font-face { 24 | font-family: 'Open Sans'; 25 | font-style: normal; 26 | font-weight: 400; 27 | src: local('Open Sans Regular'), local('OpenSans-Regular'), 28 | url('../fonts/open-sans-v17-all-charsets-regular.woff2') format('woff2'); 29 | } 30 | 31 | /* open-sans-italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 32 | @font-face { 33 | font-family: 'Open Sans'; 34 | font-style: italic; 35 | font-weight: 400; 36 | src: local('Open Sans Italic'), local('OpenSans-Italic'), 37 | url('../fonts/open-sans-v17-all-charsets-italic.woff2') format('woff2'); 38 | } 39 | 40 | /* open-sans-600 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 41 | @font-face { 42 | font-family: 'Open Sans'; 43 | font-style: normal; 44 | font-weight: 600; 45 | src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), 46 | url('../fonts/open-sans-v17-all-charsets-600.woff2') format('woff2'); 47 | } 48 | 49 | /* open-sans-600italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 50 | @font-face { 51 | font-family: 'Open Sans'; 52 | font-style: italic; 53 | font-weight: 600; 54 | src: local('Open Sans SemiBold Italic'), local('OpenSans-SemiBoldItalic'), 55 | url('../fonts/open-sans-v17-all-charsets-600italic.woff2') format('woff2'); 56 | } 57 | 58 | /* open-sans-700 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 59 | @font-face { 60 | font-family: 'Open Sans'; 61 | font-style: normal; 62 | font-weight: 700; 63 | src: local('Open Sans Bold'), local('OpenSans-Bold'), 64 | url('../fonts/open-sans-v17-all-charsets-700.woff2') format('woff2'); 65 | } 66 | 67 | /* open-sans-700italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 68 | @font-face { 69 | font-family: 'Open Sans'; 70 | font-style: italic; 71 | font-weight: 700; 72 | src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'), 73 | url('../fonts/open-sans-v17-all-charsets-700italic.woff2') format('woff2'); 74 | } 75 | 76 | /* open-sans-800 - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 77 | @font-face { 78 | font-family: 'Open Sans'; 79 | font-style: normal; 80 | font-weight: 800; 81 | src: local('Open Sans ExtraBold'), local('OpenSans-ExtraBold'), 82 | url('../fonts/open-sans-v17-all-charsets-800.woff2') format('woff2'); 83 | } 84 | 85 | /* open-sans-800italic - latin_vietnamese_latin-ext_greek-ext_greek_cyrillic-ext_cyrillic */ 86 | @font-face { 87 | font-family: 'Open Sans'; 88 | font-style: italic; 89 | font-weight: 800; 90 | src: local('Open Sans ExtraBold Italic'), local('OpenSans-ExtraBoldItalic'), 91 | url('../fonts/open-sans-v17-all-charsets-800italic.woff2') format('woff2'); 92 | } 93 | 94 | /* source-code-pro-500 - latin_vietnamese_latin-ext_greek_cyrillic-ext_cyrillic */ 95 | @font-face { 96 | font-family: 'Source Code Pro'; 97 | font-style: normal; 98 | font-weight: 500; 99 | src: url('../fonts/source-code-pro-v11-all-charsets-500.woff2') format('woff2'); 100 | } 101 | -------------------------------------------------------------------------------- /guide/resilience.md: -------------------------------------------------------------------------------- 1 | # Deal with bad internet 2 | 3 | > **Topics**: resilience, `Schedule`, circuit breaker, caching 4 | 5 | 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. 6 | 7 | This topic is well documented in the official Arrow documentation, we suggest the reader to check the [Resilience section](https://arrow-kt.io/learn/resilience/intro/), both the introduction and the pages describing [`Schedule`](https://arrow-kt.io/learn/resilience/retry-and-repeat/) and [`CircuitBreaker`](https://arrow-kt.io/learn/resilience/circuitbreaker/). 8 | 9 | ```admonish tip title="Decorator" 10 | 11 | We strongly recommend to use the [Decorator pattern](https://refactoring.guru/design-patterns/decorator) in the following tasks. For a good introduction, fully in Kotlin, check [this video](https://www.youtube.com/watch?v=erWsXSqQ-CM) by Dave Leeds. 12 | 13 | ``` 14 | 15 | ```admonish warning title="Pokémon TCG API is down" 16 | 17 | Unfortunately, the [Pokémon TCG API](https://pokemontcg.io/) we use for this exercise is routinely down. 18 | For that reason, the current code uses the [local variant](./local.md) by default. 19 | If you want to run the code against the remote API, change the constructor for `SearchViewModel`. 20 | 21 | ``` 22 | 23 | ## Retry if fails 24 | 25 | The task here is to create a wrapper that adds retry capabilities to an inner `PokemonTcgApi` instance. Explore different variations of the [`Schedule`](https://arrow-kt.io/learn/resilience/retry-and-repeat/#constructing-a-policy), from a simple fixed repetition, to exponential backoff policies. 26 | 27 | ### Use a circuit breaker 28 | 29 | Improve the previous soltuion with a [circuit breaker](https://arrow-kt.io/learn/resilience/circuitbreaker/), which ensures that we do not overload the service or the client in case the (transient) failure takes a long time to recover. 30 | 31 | As described in the [documentation](https://arrow-kt.io/learn/resilience/circuitbreaker/), the best option for resilience is to combine both approaches. 32 | 33 | > 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. 34 | 35 | ## Introduce a cache 36 | 37 | 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. 38 | 39 | 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](https://reactivecircus.github.io/cache4k/), a nice Kotlin Multiplatform option for this matter. 40 | 41 | ```admonish info title="Caching and memoization" 42 | 43 | _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`](https://arrow-kt.io/learn/collections-functions/recursive/#memoized-recursive-functions), which improved over the usual deep recursion support in Kotlin. 44 | 45 | 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. 46 | 47 | ``` -------------------------------------------------------------------------------- /guide/tech-intro.md: -------------------------------------------------------------------------------- 1 | # The technology 2 | 3 | Poké-Fun is implemented using [Kotlin](https://kotlinlang.org/), [Arrow](https://arrow-kt.io/), and [Compose Multiplatform](https://www.jetbrains.com/lp/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 😉). 4 | 5 | The one choice which goes out of the ordinary is using [Amper](https://github.com/JetBrains/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. 6 | 7 | We recommend using [IntelliJ IDEA](https://www.jetbrains.com/idea/) or [Android Studio](https://developer.android.com/studio) to work on Poké-Fun. You need at least the corresponding [Amper plug-in](https://plugins.jetbrains.com/plugin/23076-amper), and the [Kotlin Multiplatform plug-in](https://plugins.jetbrains.com/plugin/14936-kotlin-multiplatform) is highly recommended. In both cases, you should see a small play button to run the application from the IDE. 8 | 9 | ## Compose Multiplatform 10 | 11 | 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](https://react.dev/) for web, [SwiftUI](https://developer.apple.com/xcode/swiftui/) for iOS, and [Jetpack Compose](https://developer.android.com/develop/ui/compose) for Android. [Compose Multiplatform](https://www.jetbrains.com/lp/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). 12 | 13 | ```admonish info title="More about Compose Multiplatform" 14 | 15 | There is still not much documentation about Compose Multiplatform, but most of the information about Jetpack Compose (for Android) applies only with minor modifications. 16 | 17 | - [Android Basics with Compose](https://developer.android.com/courses/android-basics-compose/course), 18 | - [Jetpack Compose guides](https://developer.android.com/develop/ui/compose/documentation) from Google, 19 | - [Create a Compose Multiplarform app](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-getting-started.html), 20 | - [Philipp Lackner](https://www.youtube.com/@PhilippLackner/videos) has videos covering Compose Multiplarform. 21 | 22 | ``` 23 | 24 | Compose applications are typically built from two components: 25 | 26 | - _View models_ keep (part of) the state of the application, and communicate with the outside world. 27 | - _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. 28 | 29 | 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](https://kotlinlang.org/docs/delegated-properties.html). 30 | 31 | ```kotlin 32 | class Counter: ViewModel() { 33 | // 1. define a state, starting with 0 34 | private val _count = mutableStateOf(0) 35 | 36 | // 2. expose the state in a read-only manner 37 | val count: Int by _count 38 | 39 | // 3. operations to change the state 40 | fun increment() { 41 | _count.value++ 42 | } 43 | } 44 | ``` 45 | 46 | The view consumes this view model, and shows a button with a text indicating the amount of times it has been clicked. 47 | 48 | ```kotlin 49 | @Composable fun Screen(counter: Counter) { 50 | Button(onClick = { counter.increment() }) { 51 | Text("Clicked ${counter.count} times") 52 | } 53 | } 54 | ``` 55 | 56 | 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. 57 | 58 | Armed with this knowledge, you can read the [introduction](./intro.md) to Poké-Fun. 59 | -------------------------------------------------------------------------------- /docs/fonts/SOURCE-CODE-PRO-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /guide/build.md: -------------------------------------------------------------------------------- 1 | # Deck building 2 | 3 | > **Topics**: reducers, actions as data 4 | 5 | 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_). 6 | 7 | Let's look at the components of the `DeckViewModel`: 8 | 9 | - An _initial_ state, comprised of `Awesome Deck` as title and an empty list of cards, 10 | - A series of _operations_ (`changeTitle`, `clear`, `add`) which transform this state. 11 | 12 | Using these two elements, we can understand the whole lifetime of the application as consecutive steps, each transforming the previous state. 13 | 14 | ```mermaid 15 | graph LR; 16 | State1[State 1]; 17 | State2[State 2]; 18 | State3[State 3]; 19 | State4[...]; 20 | Initial -->|add| State1; 21 | State1 -->|changeTitle| State2; 22 | State2 -->|add| State3; 23 | State3 -->|add| State4; 24 | ``` 25 | 26 | 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. 27 | 28 | ```kotlin 29 | val currentState = 30 | transformationsUntilNow.fold(initialState) { state, transformation -> 31 | transformation.apply(state) 32 | } 33 | ``` 34 | 35 | ```admonish tldr title="Fold and reduce" 36 | 37 | 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](https://redux.js.org/) is one of the libraries that popularized this concept in JavaScript. 38 | 39 | ``` 40 | 41 | 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: 42 | 43 | ```kotlin 44 | sealed interface DeckOperation { 45 | data class ChangeTitle(val newTitle: String): DeckOperation 46 | data class AddCard(val card: Card): DeckOperation 47 | data object Clear: DeckOperation 48 | } 49 | ``` 50 | 51 | Now our view model can use a single function, and dispatch based on the operation. 52 | 53 | ```kotlin 54 | class DeckViewModel: ViewModel() { 55 | fun apply(operation: DeckOperation) = when (operation) { 56 | is DeckOperation.ChangeTitle -> { ... } 57 | is DeckOperation.AddCard -> { ... } 58 | DeckOperation.Clear -> { ... } 59 | } 60 | } 61 | ``` 62 | 63 | In the tasks below we use this idea to improve the implementation, and introduce new functionality. 64 | 65 | ```admonish info title="Initial style DSLs" 66 | 67 | 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](https://serranofp.com/inikio/) is a compiler plug-in to facilitate the development of such DSLs in Kotlin. 68 | 69 | ``` 70 | 71 | ## Move to actions as data 72 | 73 | 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. 74 | 75 | ```admonish warning title="Keeping the current state" 76 | 77 | 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. 78 | 79 | ``` 80 | 81 | ## Remove a card 82 | 83 | 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. 84 | 85 | ```admonish tip 86 | 87 | Take a look at `search/view.kt` to see how to add components to each card shown on the screen. 88 | 89 | ``` 90 | 91 | ## Undo and redo 92 | 93 | 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. 94 | 95 | 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). 96 | -------------------------------------------------------------------------------- /docs/toc.js: -------------------------------------------------------------------------------- 1 | // Populate the sidebar 2 | // 3 | // This is a script, and not included directly in the page, to control the total size of the book. 4 | // The TOC contains an entry for each page, so if each page includes a copy of the TOC, 5 | // the total size of the page becomes O(n**2). 6 | class MDBookSidebarScrollbox extends HTMLElement { 7 | constructor() { 8 | super(); 9 | } 10 | connectedCallback() { 11 | this.innerHTML = '
  1. Welcome
  2. Introduction
  3. Trading Card Games
  4. The technology
  5. Overview of the code
  6. Practice
  7. What is (in) a deck
  8. Law-abiding decks
  9. Deck building
  10. Deal with bad internet
  11. Using local data
  12. Loading and saving
  13. Better architecture
  14. Nicer UI
'; 12 | // Set the current, active page, and reveal it if it's hidden 13 | let current_page = document.location.href.toString().split("#")[0].split("?")[0]; 14 | if (current_page.endsWith("/")) { 15 | current_page += "index.html"; 16 | } 17 | var links = Array.prototype.slice.call(this.querySelectorAll("a")); 18 | var l = links.length; 19 | for (var i = 0; i < l; ++i) { 20 | var link = links[i]; 21 | var href = link.getAttribute("href"); 22 | if (href && !href.startsWith("#") && !/^(?:[a-z+]+:)?\/\//.test(href)) { 23 | link.href = path_to_root + href; 24 | } 25 | // The "index" page is supposed to alias the first chapter in the book. 26 | if (link.href === current_page || (i === 0 && path_to_root === "" && current_page.endsWith("/index.html"))) { 27 | link.classList.add("active"); 28 | var parent = link.parentElement; 29 | if (parent && parent.classList.contains("chapter-item")) { 30 | parent.classList.add("expanded"); 31 | } 32 | while (parent) { 33 | if (parent.tagName === "LI" && parent.previousElementSibling) { 34 | if (parent.previousElementSibling.classList.contains("chapter-item")) { 35 | parent.previousElementSibling.classList.add("expanded"); 36 | } 37 | } 38 | parent = parent.parentElement; 39 | } 40 | } 41 | } 42 | // Track and set sidebar scroll position 43 | this.addEventListener('click', function(e) { 44 | if (e.target.tagName === 'A') { 45 | sessionStorage.setItem('sidebar-scroll', this.scrollTop); 46 | } 47 | }, { passive: true }); 48 | var sidebarScrollTop = sessionStorage.getItem('sidebar-scroll'); 49 | sessionStorage.removeItem('sidebar-scroll'); 50 | if (sidebarScrollTop) { 51 | // preserve sidebar scroll position when navigating via links within sidebar 52 | this.scrollTop = sidebarScrollTop; 53 | } else { 54 | // scroll sidebar to current active section when navigating via "next/previous chapter" buttons 55 | var activeSection = document.querySelector('#sidebar .active'); 56 | if (activeSection) { 57 | activeSection.scrollIntoView({ block: 'center' }); 58 | } 59 | } 60 | // Toggle buttons 61 | var sidebarAnchorToggles = document.querySelectorAll('#sidebar a.toggle'); 62 | function toggleSection(ev) { 63 | ev.currentTarget.parentElement.classList.toggle('expanded'); 64 | } 65 | Array.from(sidebarAnchorToggles).forEach(function (el) { 66 | el.addEventListener('click', toggleSection); 67 | }); 68 | } 69 | } 70 | window.customElements.define("mdbook-sidebar-scrollbox", MDBookSidebarScrollbox); 71 | -------------------------------------------------------------------------------- /src/deck/view.kt: -------------------------------------------------------------------------------- 1 | package deck 2 | 3 | import androidx.compose.foundation.* 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.foundation.text.BasicTextField 6 | import androidx.compose.material.icons.Icons 7 | import androidx.compose.material.icons.automirrored.filled.Redo 8 | import androidx.compose.material.icons.automirrored.filled.Undo 9 | import androidx.compose.material.icons.filled.* 10 | import androidx.compose.material3.* 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.text.font.FontStyle 15 | import androidx.compose.ui.unit.dp 16 | import io.github.vinceglb.filekit.compose.rememberFilePickerLauncher 17 | import io.github.vinceglb.filekit.compose.rememberFileSaverLauncher 18 | import io.github.vinceglb.filekit.core.PickerType 19 | import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi 20 | import org.jetbrains.compose.splitpane.VerticalSplitPane 21 | import org.jetbrains.compose.splitpane.rememberSplitPaneState 22 | import tcg.MultipleCards 23 | import utils.VerticalSplitPaneSplitter 24 | 25 | @OptIn(ExperimentalSplitPaneApi::class, ExperimentalMaterial3Api::class) 26 | @Composable 27 | fun DeckPane( 28 | deck: DeckViewModel, 29 | modifier: Modifier = Modifier 30 | ) { 31 | Column(modifier) { 32 | TopAppBar( 33 | title = { 34 | BasicTextField( 35 | deck.deck.title, 36 | onValueChange = deck::changeTitle, 37 | textStyle = MaterialTheme.typography.headlineMedium.copy(color = MaterialTheme.colorScheme.primary), 38 | singleLine = true, 39 | ) 40 | }, 41 | actions = { 42 | IconButton( 43 | onClick = { deck.clear() } 44 | ) { Icon(Icons.Default.Delete, contentDescription = "Clear") } 45 | 46 | val openPicker = rememberFilePickerLauncher( 47 | type = PickerType.File(extensions = listOf("deck")) 48 | ) { file -> 49 | /* what to do with the chosen file */ 50 | } 51 | IconButton( 52 | onClick = { openPicker.launch() }, 53 | enabled = false 54 | ) { Icon(Icons.Default.FileOpen, contentDescription = "Open") } 55 | 56 | val savePicker = rememberFileSaverLauncher { file -> 57 | /* what to do with the chosen file */ 58 | } 59 | IconButton( 60 | onClick = { savePicker.launch(baseName = deck.deck.title, extension = "deck") }, 61 | enabled = false, 62 | ) { Icon(Icons.Default.Save, contentDescription = "Save") } 63 | 64 | VerticalDivider() 65 | IconButton( 66 | onClick = { }, 67 | enabled = false 68 | ) { Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Undo") } 69 | IconButton( 70 | onClick = { }, 71 | enabled = false 72 | ) { Icon(Icons.AutoMirrored.Filled.Redo, contentDescription = "Redo") } 73 | } 74 | ) 75 | VerticalSplitPane( 76 | splitPaneState = rememberSplitPaneState(1.0f), 77 | modifier = Modifier.fillMaxSize().padding(5.dp) 78 | ) { 79 | first { 80 | MultipleCards( 81 | cards = deck.deck.cards.sorted(), 82 | modifier = Modifier.fillMaxSize() 83 | ) 84 | } 85 | second(60.dp) { 86 | when (val problems = deck.problems) { 87 | null -> DeckProblemLine("Everything is fine :)", fontStyle = FontStyle.Italic) 88 | else -> DeckProblems( 89 | problems, 90 | Modifier.background(MaterialTheme.colorScheme.background) 91 | ) 92 | } 93 | } 94 | splitter { 95 | VerticalSplitPaneSplitter() 96 | } 97 | } 98 | } 99 | } 100 | 101 | @Composable 102 | fun DeckProblems(problems: List, modifier: Modifier = Modifier) { 103 | Surface(modifier) { 104 | Box(modifier) { 105 | val scrollState = rememberScrollState() 106 | Column( 107 | modifier = Modifier.verticalScroll(scrollState).fillMaxSize() 108 | ) { 109 | for (problem in problems) { 110 | DeckProblemLine(problem) 111 | } 112 | } 113 | VerticalScrollbar( 114 | modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), 115 | adapter = rememberScrollbarAdapter(scrollState) 116 | ) 117 | } 118 | } 119 | } 120 | 121 | @Composable 122 | fun DeckProblemLine(problem: String, fontStyle: FontStyle? = null, modifier: Modifier = Modifier) { 123 | Text(problem, fontStyle = fontStyle, modifier = modifier.padding(2.dp)) 124 | } 125 | -------------------------------------------------------------------------------- /guide/adt.md: -------------------------------------------------------------------------------- 1 | # What is a deck 2 | 3 | > **Topics**: sealed hierachies, data classes, immutability 4 | 5 | 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. 6 | 7 | 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. 8 | 9 | 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. 10 | 11 | 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 12 | 13 | ```kotlin 14 | class Result( 15 | val data: Card?, 16 | val problem: Throwable? 17 | ) 18 | ``` 19 | 20 | with the additional invariant that at most one of the values should be non-`null`, and both being `null` represents a loading state. 21 | 22 | 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, 23 | 24 | ```kotlin 25 | sealed interface Result { 26 | data object Loading: Result 27 | data class Success(val data: Card): Result 28 | data class Problem(val problem: Throwable): Result 29 | } 30 | ``` 31 | 32 | 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. 33 | 34 | ```admonish tip title="Sealed hierarchies are everywhere" 35 | 36 | 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. 37 | 38 | ``` 39 | 40 | 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. 41 | 42 | ```admonish info title="More on functional domain modeling" 43 | 44 | - [Domain modeling](https://arrow-kt.io/learn/design/domain-modeling/) in Arrow documentation. 45 | - The book [Domain modeling made functional](https://pragprog.com/titles/swdddf/domain-modeling-made-functional/) by Scott Wlaschin introduces many of these ideas in the context of F#, but it maps quite well to Kotlin. 46 | 47 | ``` 48 | 49 | ## More precise `type` 50 | 51 | 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. 52 | 53 | ## More precise energies 54 | 55 | Even the previous refinement is not completely true. In fact, two types have some special meaning in the game: 56 | 57 | - _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. 58 | - 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. 59 | 60 | | | | | 61 | |---|---|--| 62 | | ![Koraidon](https://images.pokemontcg.io/svp/91_hires.png) | ![Miraidon](https://images.pokemontcg.io/svp/92_hires.png) | These cards are of _dragon_ type, but their attacks do not use that energy (since it's forbidden). However, they both use _colorless_ energy. | 63 | | ![Chatot](https://images.pokemontcg.io/sv5/181_hires.png) | ![Snorlax](https://images.pokemontcg.io/svp/51_hires.png) | These cards are of _colorless_ type. They are used in every type of deck, since their attack cost can be paid using any energy. | 64 | 65 | 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. 66 | 67 | ## Information about evolution 68 | 69 | 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. 70 | 71 | ```admonish bug title="One direction does not imply the other" 72 | 73 | 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. 74 | 75 | ``` 76 | 77 | | | | | | 78 | |--|--|--|--| 79 | | ![Oddish](https://images.pokemontcg.io/sv3pt5/43_hires.png) | ![Gloom](https://images.pokemontcg.io/sv3pt5/44_hires.png) | ![Vileplume](https://images.pokemontcg.io/sv3pt5/45_hires.png) | ![Bellossom](https://images.pokemontcg.io/sv3/3_hires.png) | 80 | 81 | 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](https://docs.pokemontcg.io/) for the place where it appears. 82 | 83 | ```admonish info title="kotlinx.serialization" 84 | 85 | The code uses `kotlinx.serialization` to transform the JSON returned by the API into Kotlin data classes. For more information, check the [introduction](https://kotlinlang.org/docs/serialization.html#serialize-and-deserialize-json) and the [basic guide](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/basic-serialization.md). 86 | 87 | ``` 88 | 89 | 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. 90 | -------------------------------------------------------------------------------- /docs/css/general.css: -------------------------------------------------------------------------------- 1 | /* Base styles and content styles */ 2 | 3 | :root { 4 | /* Browser default font-size is 16px, this way 1 rem = 10px */ 5 | font-size: 62.5%; 6 | color-scheme: var(--color-scheme); 7 | } 8 | 9 | html { 10 | font-family: "Open Sans", sans-serif; 11 | color: var(--fg); 12 | background-color: var(--bg); 13 | text-size-adjust: none; 14 | -webkit-text-size-adjust: none; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | font-size: 1.6rem; 20 | overflow-x: hidden; 21 | } 22 | 23 | code { 24 | font-family: var(--mono-font) !important; 25 | font-size: var(--code-font-size); 26 | direction: ltr !important; 27 | } 28 | 29 | /* make long words/inline code not x overflow */ 30 | main { 31 | overflow-wrap: break-word; 32 | } 33 | 34 | /* make wide tables scroll if they overflow */ 35 | .table-wrapper { 36 | overflow-x: auto; 37 | } 38 | 39 | /* Don't change font size in headers. */ 40 | h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { 41 | font-size: unset; 42 | } 43 | 44 | .left { float: left; } 45 | .right { float: right; } 46 | .boring { opacity: 0.6; } 47 | .hide-boring .boring { display: none; } 48 | .hidden { display: none !important; } 49 | 50 | h2, h3 { margin-block-start: 2.5em; } 51 | h4, h5 { margin-block-start: 2em; } 52 | 53 | .header + .header h3, 54 | .header + .header h4, 55 | .header + .header h5 { 56 | margin-block-start: 1em; 57 | } 58 | 59 | h1:target::before, 60 | h2:target::before, 61 | h3:target::before, 62 | h4:target::before, 63 | h5:target::before, 64 | h6:target::before { 65 | display: inline-block; 66 | content: "»"; 67 | margin-inline-start: -30px; 68 | width: 30px; 69 | } 70 | 71 | /* This is broken on Safari as of version 14, but is fixed 72 | in Safari Technology Preview 117 which I think will be Safari 14.2. 73 | https://bugs.webkit.org/show_bug.cgi?id=218076 74 | */ 75 | :target { 76 | /* Safari does not support logical properties */ 77 | scroll-margin-top: calc(var(--menu-bar-height) + 0.5em); 78 | } 79 | 80 | .page { 81 | outline: 0; 82 | padding: 0 var(--page-padding); 83 | margin-block-start: calc(0px - var(--menu-bar-height)); /* Compensate for the #menu-bar-hover-placeholder */ 84 | } 85 | .page-wrapper { 86 | box-sizing: border-box; 87 | background-color: var(--bg); 88 | } 89 | html:not(.js) .page-wrapper, 90 | .js:not(.sidebar-resizing) .page-wrapper { 91 | transition: margin-left 0.3s ease, transform 0.3s ease; /* Animation: slide away */ 92 | } 93 | [dir=rtl]:not(.js) .page-wrapper, 94 | [dir=rtl].js:not(.sidebar-resizing) .page-wrapper { 95 | transition: margin-right 0.3s ease, transform 0.3s ease; /* Animation: slide away */ 96 | } 97 | 98 | .content { 99 | overflow-y: auto; 100 | padding: 0 5px 50px 5px; 101 | } 102 | .content main { 103 | margin-inline-start: auto; 104 | margin-inline-end: auto; 105 | max-width: var(--content-max-width); 106 | } 107 | .content p { line-height: 1.45em; } 108 | .content ol { line-height: 1.45em; } 109 | .content ul { line-height: 1.45em; } 110 | .content a { text-decoration: none; } 111 | .content a:hover { text-decoration: underline; } 112 | .content img, .content video { max-width: 100%; } 113 | .content .header:link, 114 | .content .header:visited { 115 | color: var(--fg); 116 | } 117 | .content .header:link, 118 | .content .header:visited:hover { 119 | text-decoration: none; 120 | } 121 | 122 | table { 123 | margin: 0 auto; 124 | border-collapse: collapse; 125 | } 126 | table td { 127 | padding: 3px 20px; 128 | border: 1px var(--table-border-color) solid; 129 | } 130 | table thead { 131 | background: var(--table-header-bg); 132 | } 133 | table thead td { 134 | font-weight: 700; 135 | border: none; 136 | } 137 | table thead th { 138 | padding: 3px 20px; 139 | } 140 | table thead tr { 141 | border: 1px var(--table-header-bg) solid; 142 | } 143 | /* Alternate background colors for rows */ 144 | table tbody tr:nth-child(2n) { 145 | background: var(--table-alternate-bg); 146 | } 147 | 148 | 149 | blockquote { 150 | margin: 20px 0; 151 | padding: 0 20px; 152 | color: var(--fg); 153 | background-color: var(--quote-bg); 154 | border-block-start: .1em solid var(--quote-border); 155 | border-block-end: .1em solid var(--quote-border); 156 | } 157 | 158 | .warning { 159 | margin: 20px; 160 | padding: 0 20px; 161 | border-inline-start: 2px solid var(--warning-border); 162 | } 163 | 164 | .warning:before { 165 | position: absolute; 166 | width: 3rem; 167 | height: 3rem; 168 | margin-inline-start: calc(-1.5rem - 21px); 169 | content: "ⓘ"; 170 | text-align: center; 171 | background-color: var(--bg); 172 | color: var(--warning-border); 173 | font-weight: bold; 174 | font-size: 2rem; 175 | } 176 | 177 | blockquote .warning:before { 178 | background-color: var(--quote-bg); 179 | } 180 | 181 | kbd { 182 | background-color: var(--table-border-color); 183 | border-radius: 4px; 184 | border: solid 1px var(--theme-popup-border); 185 | box-shadow: inset 0 -1px 0 var(--theme-hover); 186 | display: inline-block; 187 | font-size: var(--code-font-size); 188 | font-family: var(--mono-font); 189 | line-height: 10px; 190 | padding: 4px 5px; 191 | vertical-align: middle; 192 | } 193 | 194 | sup { 195 | /* Set the line-height for superscript and footnote references so that there 196 | isn't an awkward space appearing above lines that contain the footnote. 197 | 198 | See https://github.com/rust-lang/mdBook/pull/2443#discussion_r1813773583 199 | for an explanation. 200 | */ 201 | line-height: 0; 202 | } 203 | 204 | .footnote-definition { 205 | font-size: 0.9em; 206 | } 207 | /* The default spacing for a list is a little too large. */ 208 | .footnote-definition ul, 209 | .footnote-definition ol { 210 | padding-left: 20px; 211 | } 212 | .footnote-definition > li { 213 | /* Required to position the ::before target */ 214 | position: relative; 215 | } 216 | .footnote-definition > li:target { 217 | scroll-margin-top: 50vh; 218 | } 219 | .footnote-reference:target { 220 | scroll-margin-top: 50vh; 221 | } 222 | /* Draws a border around the footnote (including the marker) when it is selected. 223 | TODO: If there are multiple linkbacks, highlight which one you just came 224 | from so you know which one to click. 225 | */ 226 | .footnote-definition > li:target::before { 227 | border: 2px solid var(--footnote-highlight); 228 | border-radius: 6px; 229 | position: absolute; 230 | top: -8px; 231 | right: -8px; 232 | bottom: -8px; 233 | left: -32px; 234 | pointer-events: none; 235 | content: ""; 236 | } 237 | /* Pulses the footnote reference so you can quickly see where you left off reading. 238 | This could use some improvement. 239 | */ 240 | @media not (prefers-reduced-motion) { 241 | .footnote-reference:target { 242 | animation: fn-highlight 0.8s; 243 | border-radius: 2px; 244 | } 245 | 246 | @keyframes fn-highlight { 247 | from { 248 | background-color: var(--footnote-highlight); 249 | } 250 | } 251 | } 252 | 253 | .tooltiptext { 254 | position: absolute; 255 | visibility: hidden; 256 | color: #fff; 257 | background-color: #333; 258 | transform: translateX(-50%); /* Center by moving tooltip 50% of its width left */ 259 | left: -8px; /* Half of the width of the icon */ 260 | top: -35px; 261 | font-size: 0.8em; 262 | text-align: center; 263 | border-radius: 6px; 264 | padding: 5px 8px; 265 | margin: 5px; 266 | z-index: 1000; 267 | } 268 | .tooltipped .tooltiptext { 269 | visibility: visible; 270 | } 271 | 272 | .chapter li.part-title { 273 | color: var(--sidebar-fg); 274 | margin: 5px 0px; 275 | font-weight: bold; 276 | } 277 | 278 | .result-no-output { 279 | font-style: italic; 280 | } 281 | -------------------------------------------------------------------------------- /docs/clipboard.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * clipboard.js v2.0.4 3 | * https://zenorocha.github.io/clipboard.js 4 | * 5 | * Licensed MIT © Zeno Rocha 6 | */ 7 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return function(n){var o={};function r(t){if(o[t])return o[t].exports;var e=o[t]={i:t,l:!1,exports:{}};return n[t].call(e.exports,e,e.exports,r),e.l=!0,e.exports}return r.m=n,r.c=o,r.d=function(t,e,n){r.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},r.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return r.d(e,"a",e),e},r.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},r.p="",r(r.s=0)}([function(t,e,n){"use strict";var r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i=function(){function o(t,e){for(var n=0;n&2 182 | goto fail 183 | ) 184 | 185 | @rem URL for the JRE (see https://api.azul.com/metadata/v1/zulu/packages?release_status=ga&include_fields=java_package_features,os,arch,hw_bitness,abi,java_package_type,sha256_hash,size,archive_type,lib_c_type&java_version=25&os=macos,linux,win) 186 | @rem https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jre25.0.0-win_x64.zip 187 | @rem https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jdk25.0.0-win_aarch64.zip 188 | set jre_url=%AMPER_JRE_DOWNLOAD_ROOT%/cdn.azul.com/zulu/bin/zulu%zulu_version%-ca-%pkg_type%%java_version%-win_%jre_arch%.zip 189 | set jre_target_dir=%AMPER_BOOTSTRAP_CACHE_DIR%\zulu%zulu_version%-ca-%pkg_type%%java_version%-win_%jre_arch% 190 | call :download_and_extract "Amper runtime v%zulu_version%" "%jre_url%" "%jre_target_dir%" "%jre_sha256%" "256" "false" 191 | if errorlevel 1 goto fail 192 | 193 | set effective_amper_java_home= 194 | for /d %%d in ("%jre_target_dir%\*") do if exist "%%d\bin\java.exe" set effective_amper_java_home=%%d 195 | if not exist "%effective_amper_java_home%\bin\java.exe" ( 196 | echo Unable to find java.exe under %jre_target_dir% 197 | goto fail 198 | ) 199 | :jre_provisioned 200 | 201 | REM ********** Launch Amper ********** 202 | 203 | "%effective_amper_java_home%\bin\java.exe" ^ 204 | @"%amper_target_dir%\amper.args" ^ 205 | "-Damper.wrapper.dist.sha256=%amper_sha256%" ^ 206 | "-Damper.dist.path=%amper_target_dir%" ^ 207 | "-Damper.wrapper.path=%~f0" ^ 208 | %AMPER_JAVA_OPTIONS% ^ 209 | -cp "%amper_target_dir%\lib\*" ^ 210 | org.jetbrains.amper.cli.MainKt ^ 211 | %* 212 | exit /B %ERRORLEVEL% 213 | -------------------------------------------------------------------------------- /src/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val primaryLight = Color(0xFF0371f8) 6 | val onPrimaryLight = Color(0xFFFFFFFF) 7 | val primaryContainerLight = Color(0xFF006FEE) 8 | val onPrimaryContainerLight = Color(0xFFFFFFFF) 9 | val secondaryLight = Color(0xFF4C4D4D) 10 | val onSecondaryLight = Color(0xFFFFFFFF) 11 | val secondaryContainerLight = Color(0xFF717171) 12 | val onSecondaryContainerLight = Color(0xFFFFFFFF) 13 | val tertiaryLight = Color(0xFF7720A2) 14 | val onTertiaryLight = Color(0xFFFFFFFF) 15 | val tertiaryContainerLight = Color(0xFFA04CCA) 16 | val onTertiaryContainerLight = Color(0xFFFFFFFF) 17 | val errorLight = Color(0xFFBA1A1A) 18 | val onErrorLight = Color(0xFFFFFFFF) 19 | val errorContainerLight = Color(0xFFFFDAD6) 20 | val onErrorContainerLight = Color(0xFF410002) 21 | val backgroundLight = Color(0xFFFFFFFF) 22 | val onBackgroundLight = Color(0xFF181B23) 23 | val surfaceLight = Color(0xFFFCF8F8) 24 | val onSurfaceLight = Color(0xFF1C1B1B) 25 | val surfaceVariantLight = Color(0xFFE0E3E3) 26 | val onSurfaceVariantLight = Color(0xFF444748) 27 | val outlineLight = Color(0xFF747878) 28 | val outlineVariantLight = Color(0xFFC4C7C7) 29 | val scrimLight = Color(0xFF000000) 30 | val inverseSurfaceLight = Color(0xFF313030) 31 | val inverseOnSurfaceLight = Color(0xFFF4F0EF) 32 | val inversePrimaryLight = Color(0xFFAEC6FF) 33 | val surfaceDimLight = Color(0xFFDDD9D9) 34 | val surfaceBrightLight = Color(0xFFFCF8F8) 35 | val surfaceContainerLowestLight = Color(0xFFFFFFFF) 36 | val surfaceContainerLowLight = Color(0xFFF6F3F2) 37 | val surfaceContainerLight = Color(0xFFF1EDEC) 38 | val surfaceContainerHighLight = Color(0xFFEBE7E7) 39 | val surfaceContainerHighestLight = Color(0xFFE5E2E1) 40 | 41 | val primaryLightMediumContrast = Color(0xFF00408E) 42 | val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) 43 | val primaryContainerLightMediumContrast = Color(0xFF006FEE) 44 | val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) 45 | val secondaryLightMediumContrast = Color(0xFF424343) 46 | val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) 47 | val secondaryContainerLightMediumContrast = Color(0xFF717171) 48 | val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) 49 | val tertiaryLightMediumContrast = Color(0xFF6B0C96) 50 | val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) 51 | val tertiaryContainerLightMediumContrast = Color(0xFFA04CCA) 52 | val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) 53 | val errorLightMediumContrast = Color(0xFF8C0009) 54 | val onErrorLightMediumContrast = Color(0xFFFFFFFF) 55 | val errorContainerLightMediumContrast = Color(0xFFDA342E) 56 | val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) 57 | val backgroundLightMediumContrast = Color(0xFFF9F9FF) 58 | val onBackgroundLightMediumContrast = Color(0xFF181B23) 59 | val surfaceLightMediumContrast = Color(0xFFFCF8F8) 60 | val onSurfaceLightMediumContrast = Color(0xFF1C1B1B) 61 | val surfaceVariantLightMediumContrast = Color(0xFFE0E3E3) 62 | val onSurfaceVariantLightMediumContrast = Color(0xFF404344) 63 | val outlineLightMediumContrast = Color(0xFF5C6060) 64 | val outlineVariantLightMediumContrast = Color(0xFF787B7C) 65 | val scrimLightMediumContrast = Color(0xFF000000) 66 | val inverseSurfaceLightMediumContrast = Color(0xFF313030) 67 | val inverseOnSurfaceLightMediumContrast = Color(0xFFF4F0EF) 68 | val inversePrimaryLightMediumContrast = Color(0xFFAEC6FF) 69 | val surfaceDimLightMediumContrast = Color(0xFFDDD9D9) 70 | val surfaceBrightLightMediumContrast = Color(0xFFFCF8F8) 71 | val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) 72 | val surfaceContainerLowLightMediumContrast = Color(0xFFF6F3F2) 73 | val surfaceContainerLightMediumContrast = Color(0xFFF1EDEC) 74 | val surfaceContainerHighLightMediumContrast = Color(0xFFEBE7E7) 75 | val surfaceContainerHighestLightMediumContrast = Color(0xFFE5E2E1) 76 | 77 | val primaryLightHighContrast = Color(0xFF00204F) 78 | val onPrimaryLightHighContrast = Color(0xFFFFFFFF) 79 | val primaryContainerLightHighContrast = Color(0xFF00408E) 80 | val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) 81 | val secondaryLightHighContrast = Color(0xFF212222) 82 | val onSecondaryLightHighContrast = Color(0xFFFFFFFF) 83 | val secondaryContainerLightHighContrast = Color(0xFF424343) 84 | val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) 85 | val tertiaryLightHighContrast = Color(0xFF3B0056) 86 | val onTertiaryLightHighContrast = Color(0xFFFFFFFF) 87 | val tertiaryContainerLightHighContrast = Color(0xFF6B0C96) 88 | val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) 89 | val errorLightHighContrast = Color(0xFF4E0002) 90 | val onErrorLightHighContrast = Color(0xFFFFFFFF) 91 | val errorContainerLightHighContrast = Color(0xFF8C0009) 92 | val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) 93 | val backgroundLightHighContrast = Color(0xFFF9F9FF) 94 | val onBackgroundLightHighContrast = Color(0xFF181B23) 95 | val surfaceLightHighContrast = Color(0xFFFCF8F8) 96 | val onSurfaceLightHighContrast = Color(0xFF000000) 97 | val surfaceVariantLightHighContrast = Color(0xFFE0E3E3) 98 | val onSurfaceVariantLightHighContrast = Color(0xFF212525) 99 | val outlineLightHighContrast = Color(0xFF404344) 100 | val outlineVariantLightHighContrast = Color(0xFF404344) 101 | val scrimLightHighContrast = Color(0xFF000000) 102 | val inverseSurfaceLightHighContrast = Color(0xFF313030) 103 | val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) 104 | val inversePrimaryLightHighContrast = Color(0xFFE6ECFF) 105 | val surfaceDimLightHighContrast = Color(0xFFDDD9D9) 106 | val surfaceBrightLightHighContrast = Color(0xFFFCF8F8) 107 | val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) 108 | val surfaceContainerLowLightHighContrast = Color(0xFFF6F3F2) 109 | val surfaceContainerLightHighContrast = Color(0xFFF1EDEC) 110 | val surfaceContainerHighLightHighContrast = Color(0xFFEBE7E7) 111 | val surfaceContainerHighestLightHighContrast = Color(0xFFE5E2E1) 112 | 113 | val primaryDark = Color(0xFFAEC6FF) 114 | val onPrimaryDark = Color(0xFF002E6A) 115 | val primaryContainerDark = Color(0xFF0068E0) 116 | val onPrimaryContainerDark = Color(0xFFFFFFFF) 117 | val secondaryDark = Color(0xFFC7C6C6) 118 | val onSecondaryDark = Color(0xFF303031) 119 | val secondaryContainerDark = Color(0xFF686868) 120 | val onSecondaryContainerDark = Color(0xFFFFFFFF) 121 | val tertiaryDark = Color(0xFFE9B3FF) 122 | val onTertiaryDark = Color(0xFF510074) 123 | val tertiaryContainerDark = Color(0xFF9743C1) 124 | val onTertiaryContainerDark = Color(0xFFFFFFFF) 125 | val errorDark = Color(0xFFFFB4AB) 126 | val onErrorDark = Color(0xFF690005) 127 | val errorContainerDark = Color(0xFF93000A) 128 | val onErrorContainerDark = Color(0xFFFFDAD6) 129 | val backgroundDark = Color(0xFF10131B) 130 | val onBackgroundDark = Color(0xFFE0E2ED) 131 | val surfaceDark = Color(0xFF141313) 132 | val onSurfaceDark = Color(0xFFE5E2E1) 133 | val surfaceVariantDark = Color(0xFF444748) 134 | val onSurfaceVariantDark = Color(0xFFC4C7C7) 135 | val outlineDark = Color(0xFF8E9192) 136 | val outlineVariantDark = Color(0xFF444748) 137 | val scrimDark = Color(0xFF000000) 138 | val inverseSurfaceDark = Color(0xFFE5E2E1) 139 | val inverseOnSurfaceDark = Color(0xFF313030) 140 | val inversePrimaryDark = Color(0xFF005AC3) 141 | val surfaceDimDark = Color(0xFF141313) 142 | val surfaceBrightDark = Color(0xFF3A3939) 143 | val surfaceContainerLowestDark = Color(0xFF0E0E0E) 144 | val surfaceContainerLowDark = Color(0xFF1C1B1B) 145 | val surfaceContainerDark = Color(0xFF201F1F) 146 | val surfaceContainerHighDark = Color(0xFF2A2A2A) 147 | val surfaceContainerHighestDark = Color(0xFF353434) 148 | 149 | val primaryDarkMediumContrast = Color(0xFFB4CAFF) 150 | val onPrimaryDarkMediumContrast = Color(0xFF001538) 151 | val primaryContainerDarkMediumContrast = Color(0xFF4E8EFF) 152 | val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) 153 | val secondaryDarkMediumContrast = Color(0xFFCBCACA) 154 | val onSecondaryDarkMediumContrast = Color(0xFF151617) 155 | val secondaryContainerDarkMediumContrast = Color(0xFF919090) 156 | val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) 157 | val tertiaryDarkMediumContrast = Color(0xFFEBB9FF) 158 | val onTertiaryDarkMediumContrast = Color(0xFF29003D) 159 | val tertiaryContainerDarkMediumContrast = Color(0xFFC16CEC) 160 | val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) 161 | val errorDarkMediumContrast = Color(0xFFFFBAB1) 162 | val onErrorDarkMediumContrast = Color(0xFF370001) 163 | val errorContainerDarkMediumContrast = Color(0xFFFF5449) 164 | val onErrorContainerDarkMediumContrast = Color(0xFF000000) 165 | val backgroundDarkMediumContrast = Color(0xFF10131B) 166 | val onBackgroundDarkMediumContrast = Color(0xFFE0E2ED) 167 | val surfaceDarkMediumContrast = Color(0xFF141313) 168 | val onSurfaceDarkMediumContrast = Color(0xFFFEFAF9) 169 | val surfaceVariantDarkMediumContrast = Color(0xFF444748) 170 | val onSurfaceVariantDarkMediumContrast = Color(0xFFC8CBCC) 171 | val outlineDarkMediumContrast = Color(0xFFA0A3A4) 172 | val outlineVariantDarkMediumContrast = Color(0xFF808484) 173 | val scrimDarkMediumContrast = Color(0xFF000000) 174 | val inverseSurfaceDarkMediumContrast = Color(0xFFE5E2E1) 175 | val inverseOnSurfaceDarkMediumContrast = Color(0xFF2A2A2A) 176 | val inversePrimaryDarkMediumContrast = Color(0xFF004498) 177 | val surfaceDimDarkMediumContrast = Color(0xFF141313) 178 | val surfaceBrightDarkMediumContrast = Color(0xFF3A3939) 179 | val surfaceContainerLowestDarkMediumContrast = Color(0xFF0E0E0E) 180 | val surfaceContainerLowDarkMediumContrast = Color(0xFF1C1B1B) 181 | val surfaceContainerDarkMediumContrast = Color(0xFF201F1F) 182 | val surfaceContainerHighDarkMediumContrast = Color(0xFF2A2A2A) 183 | val surfaceContainerHighestDarkMediumContrast = Color(0xFF353434) 184 | 185 | val primaryDarkHighContrast = Color(0xFFFBFAFF) 186 | val onPrimaryDarkHighContrast = Color(0xFF000000) 187 | val primaryContainerDarkHighContrast = Color(0xFFB4CAFF) 188 | val onPrimaryContainerDarkHighContrast = Color(0xFF000000) 189 | val secondaryDarkHighContrast = Color(0xFFFCFAFA) 190 | val onSecondaryDarkHighContrast = Color(0xFF000000) 191 | val secondaryContainerDarkHighContrast = Color(0xFFCBCACA) 192 | val onSecondaryContainerDarkHighContrast = Color(0xFF000000) 193 | val tertiaryDarkHighContrast = Color(0xFFFFF9FB) 194 | val onTertiaryDarkHighContrast = Color(0xFF000000) 195 | val tertiaryContainerDarkHighContrast = Color(0xFFEBB9FF) 196 | val onTertiaryContainerDarkHighContrast = Color(0xFF000000) 197 | val errorDarkHighContrast = Color(0xFFFFF9F9) 198 | val onErrorDarkHighContrast = Color(0xFF000000) 199 | val errorContainerDarkHighContrast = Color(0xFFFFBAB1) 200 | val onErrorContainerDarkHighContrast = Color(0xFF000000) 201 | val backgroundDarkHighContrast = Color(0xFF10131B) 202 | val onBackgroundDarkHighContrast = Color(0xFFE0E2ED) 203 | val surfaceDarkHighContrast = Color(0xFF141313) 204 | val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) 205 | val surfaceVariantDarkHighContrast = Color(0xFF444748) 206 | val onSurfaceVariantDarkHighContrast = Color(0xFFF9FBFC) 207 | val outlineDarkHighContrast = Color(0xFFC8CBCC) 208 | val outlineVariantDarkHighContrast = Color(0xFFC8CBCC) 209 | val scrimDarkHighContrast = Color(0xFF000000) 210 | val inverseSurfaceDarkHighContrast = Color(0xFFE5E2E1) 211 | val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) 212 | val inversePrimaryDarkHighContrast = Color(0xFF00285E) 213 | val surfaceDimDarkHighContrast = Color(0xFF141313) 214 | val surfaceBrightDarkHighContrast = Color(0xFF3A3939) 215 | val surfaceContainerLowestDarkHighContrast = Color(0xFF0E0E0E) 216 | val surfaceContainerLowDarkHighContrast = Color(0xFF1C1B1B) 217 | val surfaceContainerDarkHighContrast = Color(0xFF201F1F) 218 | val surfaceContainerHighDarkHighContrast = Color(0xFF2A2A2A) 219 | val surfaceContainerHighestDarkHighContrast = Color(0xFF353434) 220 | -------------------------------------------------------------------------------- /docs/fonts/OPEN-SANS-LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Page not found 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | 44 | 45 | 46 | 60 | 61 | 62 | 73 | 74 | 75 | 76 | 77 | 91 | 92 | 98 | 99 | 100 | 120 | 121 |
122 | 123 |
124 | 125 | 158 | 159 | 169 | 170 | 171 | 178 | 179 |
180 |
181 |

Document not found (404)

182 |

This URL is invalid, sorry. Please use the navigation bar or search to continue.

183 | 184 |
185 | 186 | 192 |
193 |
194 | 195 | 198 | 199 |
200 | 201 | 202 | 203 | 204 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 |
223 | 224 | -------------------------------------------------------------------------------- /src/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.material3.lightColorScheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.Immutable 9 | import androidx.compose.ui.graphics.Color 10 | 11 | private val lightScheme = lightColorScheme( 12 | primary = primaryLight, 13 | onPrimary = onPrimaryLight, 14 | primaryContainer = primaryContainerLight, 15 | onPrimaryContainer = onPrimaryContainerLight, 16 | secondary = secondaryLight, 17 | onSecondary = onSecondaryLight, 18 | secondaryContainer = secondaryContainerLight, 19 | onSecondaryContainer = onSecondaryContainerLight, 20 | tertiary = tertiaryLight, 21 | onTertiary = onTertiaryLight, 22 | tertiaryContainer = tertiaryContainerLight, 23 | onTertiaryContainer = onTertiaryContainerLight, 24 | error = errorLight, 25 | onError = onErrorLight, 26 | errorContainer = errorContainerLight, 27 | onErrorContainer = onErrorContainerLight, 28 | background = backgroundLight, 29 | onBackground = onBackgroundLight, 30 | surface = surfaceLight, 31 | onSurface = onSurfaceLight, 32 | surfaceVariant = surfaceVariantLight, 33 | onSurfaceVariant = onSurfaceVariantLight, 34 | outline = outlineLight, 35 | outlineVariant = outlineVariantLight, 36 | scrim = scrimLight, 37 | inverseSurface = inverseSurfaceLight, 38 | inverseOnSurface = inverseOnSurfaceLight, 39 | inversePrimary = inversePrimaryLight, 40 | surfaceDim = surfaceDimLight, 41 | surfaceBright = surfaceBrightLight, 42 | surfaceContainerLowest = surfaceContainerLowestLight, 43 | surfaceContainerLow = surfaceContainerLowLight, 44 | surfaceContainer = surfaceContainerLight, 45 | surfaceContainerHigh = surfaceContainerHighLight, 46 | surfaceContainerHighest = surfaceContainerHighestLight, 47 | ) 48 | 49 | private val darkScheme = darkColorScheme( 50 | primary = primaryDark, 51 | onPrimary = onPrimaryDark, 52 | primaryContainer = primaryContainerDark, 53 | onPrimaryContainer = onPrimaryContainerDark, 54 | secondary = secondaryDark, 55 | onSecondary = onSecondaryDark, 56 | secondaryContainer = secondaryContainerDark, 57 | onSecondaryContainer = onSecondaryContainerDark, 58 | tertiary = tertiaryDark, 59 | onTertiary = onTertiaryDark, 60 | tertiaryContainer = tertiaryContainerDark, 61 | onTertiaryContainer = onTertiaryContainerDark, 62 | error = errorDark, 63 | onError = onErrorDark, 64 | errorContainer = errorContainerDark, 65 | onErrorContainer = onErrorContainerDark, 66 | background = backgroundDark, 67 | onBackground = onBackgroundDark, 68 | surface = surfaceDark, 69 | onSurface = onSurfaceDark, 70 | surfaceVariant = surfaceVariantDark, 71 | onSurfaceVariant = onSurfaceVariantDark, 72 | outline = outlineDark, 73 | outlineVariant = outlineVariantDark, 74 | scrim = scrimDark, 75 | inverseSurface = inverseSurfaceDark, 76 | inverseOnSurface = inverseOnSurfaceDark, 77 | inversePrimary = inversePrimaryDark, 78 | surfaceDim = surfaceDimDark, 79 | surfaceBright = surfaceBrightDark, 80 | surfaceContainerLowest = surfaceContainerLowestDark, 81 | surfaceContainerLow = surfaceContainerLowDark, 82 | surfaceContainer = surfaceContainerDark, 83 | surfaceContainerHigh = surfaceContainerHighDark, 84 | surfaceContainerHighest = surfaceContainerHighestDark, 85 | ) 86 | 87 | private val mediumContrastLightColorScheme = lightColorScheme( 88 | primary = primaryLightMediumContrast, 89 | onPrimary = onPrimaryLightMediumContrast, 90 | primaryContainer = primaryContainerLightMediumContrast, 91 | onPrimaryContainer = onPrimaryContainerLightMediumContrast, 92 | secondary = secondaryLightMediumContrast, 93 | onSecondary = onSecondaryLightMediumContrast, 94 | secondaryContainer = secondaryContainerLightMediumContrast, 95 | onSecondaryContainer = onSecondaryContainerLightMediumContrast, 96 | tertiary = tertiaryLightMediumContrast, 97 | onTertiary = onTertiaryLightMediumContrast, 98 | tertiaryContainer = tertiaryContainerLightMediumContrast, 99 | onTertiaryContainer = onTertiaryContainerLightMediumContrast, 100 | error = errorLightMediumContrast, 101 | onError = onErrorLightMediumContrast, 102 | errorContainer = errorContainerLightMediumContrast, 103 | onErrorContainer = onErrorContainerLightMediumContrast, 104 | background = backgroundLightMediumContrast, 105 | onBackground = onBackgroundLightMediumContrast, 106 | surface = surfaceLightMediumContrast, 107 | onSurface = onSurfaceLightMediumContrast, 108 | surfaceVariant = surfaceVariantLightMediumContrast, 109 | onSurfaceVariant = onSurfaceVariantLightMediumContrast, 110 | outline = outlineLightMediumContrast, 111 | outlineVariant = outlineVariantLightMediumContrast, 112 | scrim = scrimLightMediumContrast, 113 | inverseSurface = inverseSurfaceLightMediumContrast, 114 | inverseOnSurface = inverseOnSurfaceLightMediumContrast, 115 | inversePrimary = inversePrimaryLightMediumContrast, 116 | surfaceDim = surfaceDimLightMediumContrast, 117 | surfaceBright = surfaceBrightLightMediumContrast, 118 | surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, 119 | surfaceContainerLow = surfaceContainerLowLightMediumContrast, 120 | surfaceContainer = surfaceContainerLightMediumContrast, 121 | surfaceContainerHigh = surfaceContainerHighLightMediumContrast, 122 | surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, 123 | ) 124 | 125 | private val highContrastLightColorScheme = lightColorScheme( 126 | primary = primaryLightHighContrast, 127 | onPrimary = onPrimaryLightHighContrast, 128 | primaryContainer = primaryContainerLightHighContrast, 129 | onPrimaryContainer = onPrimaryContainerLightHighContrast, 130 | secondary = secondaryLightHighContrast, 131 | onSecondary = onSecondaryLightHighContrast, 132 | secondaryContainer = secondaryContainerLightHighContrast, 133 | onSecondaryContainer = onSecondaryContainerLightHighContrast, 134 | tertiary = tertiaryLightHighContrast, 135 | onTertiary = onTertiaryLightHighContrast, 136 | tertiaryContainer = tertiaryContainerLightHighContrast, 137 | onTertiaryContainer = onTertiaryContainerLightHighContrast, 138 | error = errorLightHighContrast, 139 | onError = onErrorLightHighContrast, 140 | errorContainer = errorContainerLightHighContrast, 141 | onErrorContainer = onErrorContainerLightHighContrast, 142 | background = backgroundLightHighContrast, 143 | onBackground = onBackgroundLightHighContrast, 144 | surface = surfaceLightHighContrast, 145 | onSurface = onSurfaceLightHighContrast, 146 | surfaceVariant = surfaceVariantLightHighContrast, 147 | onSurfaceVariant = onSurfaceVariantLightHighContrast, 148 | outline = outlineLightHighContrast, 149 | outlineVariant = outlineVariantLightHighContrast, 150 | scrim = scrimLightHighContrast, 151 | inverseSurface = inverseSurfaceLightHighContrast, 152 | inverseOnSurface = inverseOnSurfaceLightHighContrast, 153 | inversePrimary = inversePrimaryLightHighContrast, 154 | surfaceDim = surfaceDimLightHighContrast, 155 | surfaceBright = surfaceBrightLightHighContrast, 156 | surfaceContainerLowest = surfaceContainerLowestLightHighContrast, 157 | surfaceContainerLow = surfaceContainerLowLightHighContrast, 158 | surfaceContainer = surfaceContainerLightHighContrast, 159 | surfaceContainerHigh = surfaceContainerHighLightHighContrast, 160 | surfaceContainerHighest = surfaceContainerHighestLightHighContrast, 161 | ) 162 | 163 | private val mediumContrastDarkColorScheme = darkColorScheme( 164 | primary = primaryDarkMediumContrast, 165 | onPrimary = onPrimaryDarkMediumContrast, 166 | primaryContainer = primaryContainerDarkMediumContrast, 167 | onPrimaryContainer = onPrimaryContainerDarkMediumContrast, 168 | secondary = secondaryDarkMediumContrast, 169 | onSecondary = onSecondaryDarkMediumContrast, 170 | secondaryContainer = secondaryContainerDarkMediumContrast, 171 | onSecondaryContainer = onSecondaryContainerDarkMediumContrast, 172 | tertiary = tertiaryDarkMediumContrast, 173 | onTertiary = onTertiaryDarkMediumContrast, 174 | tertiaryContainer = tertiaryContainerDarkMediumContrast, 175 | onTertiaryContainer = onTertiaryContainerDarkMediumContrast, 176 | error = errorDarkMediumContrast, 177 | onError = onErrorDarkMediumContrast, 178 | errorContainer = errorContainerDarkMediumContrast, 179 | onErrorContainer = onErrorContainerDarkMediumContrast, 180 | background = backgroundDarkMediumContrast, 181 | onBackground = onBackgroundDarkMediumContrast, 182 | surface = surfaceDarkMediumContrast, 183 | onSurface = onSurfaceDarkMediumContrast, 184 | surfaceVariant = surfaceVariantDarkMediumContrast, 185 | onSurfaceVariant = onSurfaceVariantDarkMediumContrast, 186 | outline = outlineDarkMediumContrast, 187 | outlineVariant = outlineVariantDarkMediumContrast, 188 | scrim = scrimDarkMediumContrast, 189 | inverseSurface = inverseSurfaceDarkMediumContrast, 190 | inverseOnSurface = inverseOnSurfaceDarkMediumContrast, 191 | inversePrimary = inversePrimaryDarkMediumContrast, 192 | surfaceDim = surfaceDimDarkMediumContrast, 193 | surfaceBright = surfaceBrightDarkMediumContrast, 194 | surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, 195 | surfaceContainerLow = surfaceContainerLowDarkMediumContrast, 196 | surfaceContainer = surfaceContainerDarkMediumContrast, 197 | surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, 198 | surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, 199 | ) 200 | 201 | private val highContrastDarkColorScheme = darkColorScheme( 202 | primary = primaryDarkHighContrast, 203 | onPrimary = onPrimaryDarkHighContrast, 204 | primaryContainer = primaryContainerDarkHighContrast, 205 | onPrimaryContainer = onPrimaryContainerDarkHighContrast, 206 | secondary = secondaryDarkHighContrast, 207 | onSecondary = onSecondaryDarkHighContrast, 208 | secondaryContainer = secondaryContainerDarkHighContrast, 209 | onSecondaryContainer = onSecondaryContainerDarkHighContrast, 210 | tertiary = tertiaryDarkHighContrast, 211 | onTertiary = onTertiaryDarkHighContrast, 212 | tertiaryContainer = tertiaryContainerDarkHighContrast, 213 | onTertiaryContainer = onTertiaryContainerDarkHighContrast, 214 | error = errorDarkHighContrast, 215 | onError = onErrorDarkHighContrast, 216 | errorContainer = errorContainerDarkHighContrast, 217 | onErrorContainer = onErrorContainerDarkHighContrast, 218 | background = backgroundDarkHighContrast, 219 | onBackground = onBackgroundDarkHighContrast, 220 | surface = surfaceDarkHighContrast, 221 | onSurface = onSurfaceDarkHighContrast, 222 | surfaceVariant = surfaceVariantDarkHighContrast, 223 | onSurfaceVariant = onSurfaceVariantDarkHighContrast, 224 | outline = outlineDarkHighContrast, 225 | outlineVariant = outlineVariantDarkHighContrast, 226 | scrim = scrimDarkHighContrast, 227 | inverseSurface = inverseSurfaceDarkHighContrast, 228 | inverseOnSurface = inverseOnSurfaceDarkHighContrast, 229 | inversePrimary = inversePrimaryDarkHighContrast, 230 | surfaceDim = surfaceDimDarkHighContrast, 231 | surfaceBright = surfaceBrightDarkHighContrast, 232 | surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, 233 | surfaceContainerLow = surfaceContainerLowDarkHighContrast, 234 | surfaceContainer = surfaceContainerDarkHighContrast, 235 | surfaceContainerHigh = surfaceContainerHighDarkHighContrast, 236 | surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, 237 | ) 238 | 239 | @Immutable 240 | data class ColorFamily( 241 | val color: Color, 242 | val onColor: Color, 243 | val colorContainer: Color, 244 | val onColorContainer: Color 245 | ) 246 | 247 | val unspecified_scheme = ColorFamily( 248 | Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified 249 | ) 250 | 251 | @Composable 252 | fun AppTheme( 253 | darkTheme: Boolean = isSystemInDarkTheme(), 254 | content: @Composable() () -> Unit 255 | ) { 256 | val colorScheme = when { 257 | darkTheme -> darkScheme 258 | else -> lightScheme 259 | } 260 | 261 | MaterialTheme( 262 | colorScheme = colorScheme, 263 | typography = AppTypography, 264 | content = content 265 | ) 266 | } 267 | -------------------------------------------------------------------------------- /amper: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. 5 | # 6 | 7 | # Possible environment variables: 8 | # AMPER_DOWNLOAD_ROOT Maven repository to download Amper dist from. 9 | # default: https://packages.jetbrains.team/maven/p/amper/amper 10 | # AMPER_JRE_DOWNLOAD_ROOT Url prefix to download Amper JRE from. 11 | # default: https:/ 12 | # AMPER_BOOTSTRAP_CACHE_DIR Cache directory to store extracted JRE and Amper distribution 13 | # AMPER_JAVA_HOME JRE to run Amper itself (optional, does not affect compilation) 14 | # AMPER_JAVA_OPTIONS JVM options to pass to the JVM running Amper (does not affect the user's application) 15 | # AMPER_NO_WELCOME_BANNER Disables the first-run welcome message if set to a non-empty value 16 | 17 | set -e -u 18 | 19 | # The version of the Amper distribution to provision and use 20 | amper_version=0.9.0 21 | # Establish chain of trust from here by specifying exact checksum of Amper distribution to be run 22 | amper_sha256=77227bb5be7091cae69ffbfff2594b9989c7ecab274f8c2a35ba8b9b6a8ef3bb 23 | 24 | AMPER_DOWNLOAD_ROOT="${AMPER_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}" 25 | AMPER_JRE_DOWNLOAD_ROOT="${AMPER_JRE_DOWNLOAD_ROOT:-https:/}" 26 | 27 | die() { 28 | echo >&2 29 | echo "$@" >&2 30 | echo >&2 31 | exit 1 32 | } 33 | 34 | download_and_extract() { 35 | moniker="$1" 36 | file_url="$2" 37 | file_sha="$3" 38 | sha_size="$4" 39 | cache_dir="$5" 40 | extract_dir="$6" 41 | show_banner_on_cache_miss="$7" 42 | 43 | if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then 44 | # Everything is up-to-date in $extract_dir, do nothing 45 | return 0; 46 | fi 47 | 48 | mkdir -p "$cache_dir" 49 | 50 | # Take a lock for the download of this file 51 | short_sha=$(echo "$file_sha" | cut -c1-32) # cannot use the ${short_sha:0:32} syntax in regular /bin/sh 52 | download_lock_file="$cache_dir/download-${short_sha}.lock" 53 | process_lock_file="$cache_dir/download-${short_sha}.$$.lock" 54 | echo $$ >"$process_lock_file" 55 | while ! ln "$process_lock_file" "$download_lock_file" 2>/dev/null; do 56 | lock_owner=$(cat "$download_lock_file" 2>/dev/null || true) 57 | if [ -n "$lock_owner" ] && ps -p "$lock_owner" >/dev/null; then 58 | echo "Another Amper instance (pid $lock_owner) is downloading $moniker. Awaiting the result..." 59 | sleep 1 60 | elif [ -n "$lock_owner" ] && [ "$(cat "$download_lock_file" 2>/dev/null)" = "$lock_owner" ]; then 61 | rm -f "$download_lock_file" 62 | # We don't want to simply loop again here, because multiple concurrent processes may face this at the same time, 63 | # which means the 'rm' command above from another script could delete our new valid lock file. Instead, we just 64 | # ask the user to try again. This doesn't 100% eliminate the race, but the probability of issues is drastically 65 | # reduced because it would involve 4 processes with perfect timing. We can revisit this later. 66 | die "Another Amper instance (pid $lock_owner) locked the download of $moniker, but is no longer running. The lock file is now removed, please try again." 67 | fi 68 | done 69 | 70 | # shellcheck disable=SC2064 71 | trap "rm -f \"$download_lock_file\"" EXIT 72 | rm -f "$process_lock_file" 73 | 74 | unlock_and_cleanup() { 75 | rm -f "$download_lock_file" 76 | trap - EXIT 77 | return 0 78 | } 79 | 80 | if [ -e "$extract_dir/.flag" ] && [ "$(cat "$extract_dir/.flag")" = "${file_sha}" ]; then 81 | # Everything is up-to-date in $extract_dir, just release the lock 82 | unlock_and_cleanup 83 | return 0; 84 | fi 85 | 86 | if [ "$show_banner_on_cache_miss" = "true" ] && [ -z "${AMPER_NO_WELCOME_BANNER:-}" ]; then 87 | echo 88 | echo ' _____ Welcome to ' 89 | echo ' /:::::| ____ ___ ____ ____ __ ___ ' 90 | echo ' /::/|::| |::::\_|:::\ |:::::\ /::::\ |::|/:::| ' 91 | echo ' /::/ |::| |::|\:::|\::\ |::|\::\ /:/__\:\ |:::/ ' 92 | echo ' /::/__|::| |::| |::| |::| |::| |::|:::::::/ |::| ' 93 | echo ' /:::::::::| |::| |::| |::| |::|/::/ \::\__ |::| ' 94 | echo ' /::/ |::| |::| |::| |::| |:::::/ \::::| |::| ' 95 | echo ' |::| ' 96 | echo " |::| v.$amper_version " 97 | echo 98 | echo "This is the first run of this version, so we need to download the actual Amper distribution." 99 | echo "Please give us a few seconds, subsequent runs will be faster." 100 | echo 101 | fi 102 | 103 | echo "Downloading $moniker..." 104 | 105 | temp_file="$cache_dir/download-file-$$.bin" 106 | rm -f "$temp_file" 107 | if command -v curl >/dev/null 2>&1; then 108 | if [ -t 1 ]; then CURL_PROGRESS="--progress-bar"; else CURL_PROGRESS="--silent --show-error"; fi 109 | # shellcheck disable=SC2086 110 | curl $CURL_PROGRESS -L --fail --retry 5 --connect-timeout 30 --output "${temp_file}" "$file_url" 111 | elif command -v wget >/dev/null 2>&1; then 112 | if [ -t 1 ]; then WGET_PROGRESS=""; else WGET_PROGRESS="-nv"; fi 113 | wget $WGET_PROGRESS --tries=5 --connect-timeout=30 --read-timeout=120 -O "${temp_file}" "$file_url" 114 | else 115 | die "ERROR: Please install 'wget' or 'curl', as Amper needs one of them to download $moniker" 116 | fi 117 | 118 | check_sha "$file_url" "$temp_file" "$file_sha" "$sha_size" 119 | 120 | rm -rf "$extract_dir" 121 | mkdir -p "$extract_dir" 122 | 123 | case "$file_url" in 124 | *".zip") 125 | if command -v unzip >/dev/null 2>&1; then 126 | unzip -q "$temp_file" -d "$extract_dir" 127 | else 128 | die "ERROR: Please install 'unzip', as Amper needs it to extract $moniker" 129 | fi ;; 130 | *) 131 | if command -v tar >/dev/null 2>&1; then 132 | tar -x -f "$temp_file" -C "$extract_dir" 133 | else 134 | die "ERROR: Please install 'tar', as Amper needs it to extract $moniker" 135 | fi ;; 136 | esac 137 | 138 | rm -f "$temp_file" 139 | 140 | echo "$file_sha" >"$extract_dir/.flag" 141 | 142 | # Unlock and cleanup the lock file 143 | unlock_and_cleanup 144 | 145 | echo "Download complete." 146 | echo 147 | } 148 | 149 | # usage: check_sha SOURCE_MONIKER FILE SHA_CHECKSUM SHA_SIZE 150 | # $1 SOURCE_MONIKER (e.g. url) 151 | # $2 FILE 152 | # $3 SHA hex string 153 | # $4 SHA size in bits (256, 512, ...) 154 | check_sha() { 155 | sha_size=$4 156 | if command -v shasum >/dev/null 2>&1; then 157 | echo "$3 *$2" | shasum -a "$sha_size" --status -c || { 158 | die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $(shasum --binary -a "$sha_size" "$2" | awk '{print $1}')" 159 | } 160 | return 0 161 | fi 162 | 163 | shaNsumCommand="sha${sha_size}sum" 164 | if command -v "$shaNsumCommand" >/dev/null 2>&1; then 165 | echo "$3 *$2" | $shaNsumCommand -w -c || { 166 | die "ERROR: Checksum mismatch for $2 (downloaded from $1): expected checksum $3 but got $($shaNsumCommand "$2" | awk '{print $1}')" 167 | } 168 | return 0 169 | fi 170 | 171 | echo "Both 'shasum' and 'sha${sha_size}sum' utilities are missing. Please install one of them" 172 | return 1 173 | } 174 | 175 | # ********** System detection ********** 176 | 177 | kernelName=$(uname -s) 178 | arch=$(uname -m) 179 | case "$kernelName" in 180 | Darwin* ) 181 | simpleOs="macos" 182 | jre_os="macosx" 183 | jre_archive_type="tar.gz" 184 | default_amper_cache_dir="$HOME/Library/Caches/JetBrains/Amper" 185 | ;; 186 | Linux* ) 187 | simpleOs="linux" 188 | jre_os="linux" 189 | jre_archive_type="tar.gz" 190 | default_amper_cache_dir="$HOME/.cache/JetBrains/Amper" 191 | # If linux runs in 32-bit mode, we want the "fake" 32-bit architecture, not the real hardware, 192 | # because in this mode linux cannot run 64-bit binaries. 193 | # shellcheck disable=SC2046 194 | arch=$(linux$(getconf LONG_BIT) uname -m) 195 | ;; 196 | CYGWIN* | MSYS* | MINGW* ) 197 | simpleOs="windows" 198 | jre_os="win" 199 | jre_archive_type=zip 200 | if command -v cygpath >/dev/null 2>&1; then 201 | default_amper_cache_dir=$(cygpath -u "$LOCALAPPDATA\JetBrains\Amper") 202 | else 203 | die "The 'cypath' command is not available, but Amper needs it. Use amper.bat instead, or try a Cygwin or MSYS environment." 204 | fi 205 | ;; 206 | *) 207 | die "Unsupported platform $kernelName" 208 | ;; 209 | esac 210 | 211 | amper_cache_dir="${AMPER_BOOTSTRAP_CACHE_DIR:-$default_amper_cache_dir}" 212 | 213 | # ********** Provision Amper distribution ********** 214 | 215 | amper_url="$AMPER_DOWNLOAD_ROOT/org/jetbrains/amper/amper-cli/$amper_version/amper-cli-$amper_version-dist.tgz" 216 | amper_target_dir="$amper_cache_dir/amper-cli-$amper_version" 217 | download_and_extract "Amper distribution v$amper_version" "$amper_url" "$amper_sha256" 256 "$amper_cache_dir" "$amper_target_dir" "true" 218 | 219 | # ********** Provision JRE for Amper ********** 220 | 221 | if [ "x${AMPER_JAVA_HOME:-}" = "x" ]; then 222 | case $arch in 223 | x86_64 | x64) jre_arch="x64" ;; 224 | aarch64 | arm64) jre_arch="aarch64" ;; 225 | *) die "Unsupported architecture $arch" ;; 226 | esac 227 | 228 | # Auto-updated from syncVersions.main.kts, do not modify directly here 229 | zulu_version=25.28.85 230 | java_version=25.0.0 231 | 232 | pkg_type=jre 233 | platform="$jre_os $jre_arch" 234 | case $platform in 235 | "macosx x64") jre_sha256=a73455c80413daa31af6b09589e3655bb8b8b91e4aa884ca7c91dc5552b9e974 ;; 236 | "macosx aarch64") jre_sha256=37316ebea9709eb4f8bc58f0ddd2f58e53720d3e7df6f78c64125915b44d322d ;; 237 | "linux x64") jre_sha256=807e96e43db00af3390a591ed40f2c8c35f7f475fb14b6061dfb19db33702cba ;; 238 | "linux aarch64") jre_sha256=ad75e426e3f101cfa018f65fde07d82b10337d4f85250ca988474d59891c5f50 ;; 239 | "win x64") jre_sha256=d3c5db7864e6412ce3971c0b065def64942d7b0f3d02581f7f0472cac21fbba9 ;; 240 | "win aarch64") jre_sha256=f5f6d8a913695649e8e2607fe0dc79c81953b2583013ac1fb977c63cb4935bfb; pkg_type=jdk ;; 241 | *) die "Unsupported platform $platform" ;; 242 | esac 243 | 244 | # URL for the JRE (see https://api.azul.com/metadata/v1/zulu/packages?release_status=ga&include_fields=java_package_features,os,arch,hw_bitness,abi,java_package_type,sha256_hash,size,archive_type,lib_c_type&java_version=25&os=macos,linux,win) 245 | # https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jre25.0.0-macosx_aarch64.tar.gz 246 | # https://cdn.azul.com/zulu/bin/zulu25.28.85-ca-jre25.0.0-linux_x64.tar.gz 247 | jre_url="$AMPER_JRE_DOWNLOAD_ROOT/cdn.azul.com/zulu/bin/zulu$zulu_version-ca-$pkg_type$java_version-${jre_os}_$jre_arch.$jre_archive_type" 248 | jre_target_dir="$amper_cache_dir/zulu$zulu_version-ca-$pkg_type$java_version-${jre_os}_$jre_arch" 249 | 250 | download_and_extract "Amper runtime v$zulu_version" "$jre_url" "$jre_sha256" 256 "$amper_cache_dir" "$jre_target_dir" "false" 251 | 252 | effective_amper_java_home= 253 | for d in "$jre_target_dir" "$jre_target_dir"/* "$jre_target_dir"/Contents/Home "$jre_target_dir"/*/Contents/Home; do 254 | if [ -e "$d/bin/java" ]; then 255 | effective_amper_java_home="$d" 256 | fi 257 | done 258 | 259 | if [ "x${effective_amper_java_home:-}" = "x" ]; then 260 | die "Unable to find bin/java under $jre_target_dir" 261 | fi 262 | else 263 | effective_amper_java_home="$AMPER_JAVA_HOME" 264 | fi 265 | 266 | java_exe="$effective_amper_java_home/bin/java" 267 | if [ '!' -x "$java_exe" ]; then 268 | die "Unable to find bin/java executable at $java_exe" 269 | fi 270 | 271 | # ********** Script path detection ********** 272 | 273 | # We might need to resolve symbolic links here 274 | wrapper_path=$(realpath "$0") 275 | 276 | # ********** Launch Amper ********** 277 | 278 | # In this section we construct the command line by prepending arguments from biggest to lowest precedence: 279 | # 1. basic main class, CLI arguments, and classpath 280 | # 2. user JVM args (AMPER_JAVA_OPTIONS) 281 | # 3. default JVM args (prepended last, which means they appear first, so they are overridden by user args) 282 | 283 | # 1. Prepend basic launch arguments 284 | if [ "$simpleOs" = "windows" ]; then 285 | # Can't cygpath the '*' so it has to be outside 286 | classpath="$(cygpath -w "$amper_target_dir")\lib\*" 287 | else 288 | classpath="$amper_target_dir/lib/*" 289 | fi 290 | 291 | set -- -cp "$classpath" org.jetbrains.amper.cli.MainKt "$@" 292 | 293 | # 2. Prepend user JVM args from AMPER_JAVA_OPTS 294 | # 295 | # We use "xargs" to parse quoted JVM args from inside AMPER_JAVA_OPTS. 296 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 297 | # 298 | # In Bash we could simply go: 299 | # 300 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 301 | # set -- "${ARGS[@]}" "$@" 302 | # 303 | # but POSIX shell has neither arrays nor command substitution, so instead we 304 | # post-process each arg (as a line of input to sed) to backslash-escape any 305 | # character that might be a shell metacharacter, then use eval to reverse 306 | # that process (while maintaining the separation between arguments), and wrap 307 | # the whole thing up as a single "set" statement. 308 | # 309 | # This will of course break if any of these variables contains a newline or 310 | # an unmatched quote. 311 | if [ -n "${AMPER_JAVA_OPTIONS:-}" ]; then 312 | eval "set -- $( 313 | printf '%s\n' "$AMPER_JAVA_OPTIONS" | 314 | xargs -n1 | 315 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 316 | tr '\n' ' ' 317 | )" '"$@"' 318 | fi 319 | 320 | # 3. Prepend default JVM args 321 | set -- \ 322 | @"$amper_target_dir/amper.args" \ 323 | "-Damper.wrapper.dist.sha256=$amper_sha256" \ 324 | "-Damper.dist.path=$amper_target_dir" \ 325 | "-Damper.wrapper.path=$wrapper_path" \ 326 | "$@" 327 | 328 | # Then we can launch with the overridden $@ arguments 329 | exec "$java_exe" "$@" 330 | --------------------------------------------------------------------------------