feature: init kotlin compose multiplatforme
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user