feature: init kotlin compose multiplatforme
Tests / build (pull_request) Successful in 7m44s
Tests / test (pull_request) Failing after 10m37s
Tests / lint (pull_request) Successful in 14m30s

This commit is contained in:
2026-08-06 21:28:57 +02:00
parent 505cfe38f0
commit 774e80d9d5
196 changed files with 1297 additions and 688 deletions
+135
View File
@@ -0,0 +1,135 @@
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val kotlinSerializationVersion: Provider<String> = providers.gradleProperty("kotlin_serialization_version")
val kotlinxCoroutinesVersion: Provider<String> = providers.gradleProperty("kotlinx_coroutines_version")
val ktorVersion: Provider<String> = providers.gradleProperty("ktor_version")
val navigationComposeVersion: Provider<String> = providers.gradleProperty("navigation_compose_version")
val androidCompileSdk: Provider<String> = providers.gradleProperty("android_compile_sdk")
val androidTargetSdk: Provider<String> = providers.gradleProperty("android_target_sdk")
val androidMinSdk: Provider<String> = providers.gradleProperty("android_min_sdk")
plugins {
kotlin("multiplatform")
kotlin("plugin.compose")
kotlin("plugin.serialization")
id("org.jetbrains.compose")
id("com.android.application")
}
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
jvm("desktop") {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName = "eventDemoApp"
browser {
commonWebpackConfig {
outputFileName = "eventDemoApp.js"
}
}
binaries.executable()
}
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlin.uuid.ExperimentalUuidApi")
}
sourceSets {
commonMain.dependencies {
implementation(project(":shared"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation("org.jetbrains.androidx.navigation:navigation-compose:${navigationComposeVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlinxCoroutinesVersion.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${kotlinSerializationVersion.get()}")
implementation("io.ktor:ktor-client-core:${ktorVersion.get()}")
implementation("io.ktor:ktor-client-content-negotiation:${ktorVersion.get()}")
implementation("io.ktor:ktor-client-websockets:${ktorVersion.get()}")
implementation("io.ktor:ktor-serialization-kotlinx-json:${ktorVersion.get()}")
}
androidMain.dependencies {
implementation("androidx.activity:activity-compose:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${kotlinxCoroutinesVersion.get()}")
implementation("io.ktor:ktor-client-okhttp:${ktorVersion.get()}")
}
getByName("desktopMain").dependencies {
implementation(compose.desktop.currentOs)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:${kotlinxCoroutinesVersion.get()}")
implementation("io.ktor:ktor-client-cio:${ktorVersion.get()}")
}
wasmJsMain.dependencies {
implementation("io.ktor:ktor-client-js:${ktorVersion.get()}")
}
}
}
android {
namespace = "io.github.flecomte.eventdemo.app"
compileSdk = androidCompileSdk.get().toInt()
defaultConfig {
applicationId = "io.github.flecomte.eventdemo.app"
minSdk = androidMinSdk.get().toInt()
targetSdk = androidTargetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
compose.desktop {
application {
mainClass = "eventDemo.app.MainKt"
nativeDistributions {
targetFormats(
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
)
packageName = "EventDemo"
packageVersion = "1.0.0"
}
}
}
@@ -0,0 +1,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="EventDemo"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboard|keyboardHidden"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,14 @@
package eventDemo.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}
@@ -0,0 +1,154 @@
package eventDemo.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eventDemo.app.network.ApiClient
import eventDemo.shared.game.projection.GameList
import kotlinx.coroutines.launch
private enum class Screen { LOGIN, GAMES }
@Composable
fun App() {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val apiClient = remember { ApiClient() }
var screen by remember { mutableStateOf(Screen.LOGIN) }
when (screen) {
Screen.LOGIN -> LoginScreen(apiClient) { screen = Screen.GAMES }
Screen.GAMES -> GamesScreen(apiClient) { screen = Screen.LOGIN }
}
}
}
}
@Composable
private fun LoginScreen(
apiClient: ApiClient,
onLoggedIn: () -> Unit,
) {
val scope = rememberCoroutineScope()
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("EventDemo", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp))
}
Button(
onClick = {
errorMessage = null
isLoading = true
scope.launch {
apiClient
.login(username, password)
.onSuccess { onLoggedIn() }
.onFailure { errorMessage = it.message ?: "Login failed" }
isLoading = false
}
},
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.padding(top = 16.dp),
) {
Text(if (isLoading) "Signing in..." else "Sign in")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GamesScreen(
apiClient: ApiClient,
onLoggedOut: () -> Unit,
) {
val scope = rememberCoroutineScope()
var games by remember { mutableStateOf<List<GameList>>(emptyList()) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(true) }
fun refresh() {
isLoading = true
scope.launch {
apiClient
.listGames()
.onSuccess { games = it }
.onFailure { errorMessage = it.message ?: "Could not load games" }
isLoading = false
}
}
remember { refresh() }
Scaffold(
topBar = { TopAppBar(title = { Text("Games") }) },
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
when {
isLoading -> CircularProgressIndicator()
errorMessage != null -> Text(errorMessage ?: "")
games.isEmpty() -> Text("No game yet.")
else ->
LazyColumn {
items(games) { game ->
Text("${game.aggregateId} - ${game.status} - ${game.players.size} player(s)")
}
}
}
Button(onClick = { refresh() }, modifier = Modifier.padding(top = 16.dp)) {
Text("Refresh")
}
Button(
onClick = {
apiClient.logout()
onLoggedOut()
},
modifier = Modifier.padding(top = 8.dp),
) {
Text("Sign out")
}
}
}
}
@@ -0,0 +1,63 @@
package eventDemo.app.network
import eventDemo.shared.game.projection.GameList
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Talks to the Ktor backend over plain HTTP.
* `baseUrl` defaults to the dev Traefik route documented in doc/installation.md.
*/
class ApiClient(
private val baseUrl: String = "http://api.traefik.me",
) {
private var token: String? = null
private val client =
HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
}
val isAuthenticated: Boolean
get() = token != null
suspend fun login(
username: String,
password: String,
): Result<Unit> =
runCatching {
val response: Map<String, String> =
client
.post("$baseUrl/login/$username") {
parameter("password", password)
}.body()
token = response["token"] ?: error("Missing token in login response")
}
suspend fun listGames(): Result<List<GameList>> =
runCatching {
val currentToken = token ?: error("Not authenticated")
client
.get("$baseUrl/games") {
header(HttpHeaders.Authorization, "Bearer $currentToken")
}.body()
}
fun logout() {
token = null
}
}
@@ -0,0 +1,11 @@
package eventDemo.app
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() =
application {
Window(onCloseRequest = ::exitApplication, title = "EventDemo") {
App()
}
}
@@ -0,0 +1,12 @@
package eventDemo.app
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(document.body!!) {
App()
}
}
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>EventDemo</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
}
</style>
</head>
<body>
<script src="eventDemoApp.js"></script>
</body>
</html>