chore(MP-23): network DI client, frontend architecture guards, detekt & ktlint setup, docs, ping DI factory (#21)
* chore(MP-21): snapshot pre-refactor state (Epic 1) * chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template * MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert * MP-23 Epic 3: Gradle/Build Governance zentralisieren
This commit is contained in:
@@ -0,0 +1 @@
|
||||
expect fun isDevelopmentMode(): Boolean
|
||||
@@ -0,0 +1,259 @@
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import at.mocode.clients.shared.navigation.AppScreen
|
||||
import at.mocode.clients.authfeature.AuthenticatedHttpClient
|
||||
import at.mocode.clients.authfeature.AuthTokenManager
|
||||
import at.mocode.clients.pingfeature.PingScreen
|
||||
import at.mocode.clients.pingfeature.PingViewModel
|
||||
import at.mocode.clients.shared.core.AppConstants
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import at.mocode.clients.authfeature.AuthApiClient
|
||||
import at.mocode.clients.authfeature.oauth.OAuthPkceService
|
||||
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
|
||||
import at.mocode.clients.authfeature.oauth.CallbackParams
|
||||
|
||||
@Composable
|
||||
fun MainApp() {
|
||||
MaterialTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
|
||||
|
||||
val authTokenManager = remember { AuthenticatedHttpClient.getAuthTokenManager() }
|
||||
val pingViewModel = remember { PingViewModel() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Handle PKCE callback on an app load (web)
|
||||
LaunchedEffect(Unit) {
|
||||
val callback: CallbackParams? = AuthCallbackParams.parse()
|
||||
if (callback != null) {
|
||||
val code = callback.code
|
||||
val state = callback.state
|
||||
val pkce = OAuthPkceService.current()
|
||||
if (pkce != null && pkce.state == state) {
|
||||
val api = AuthApiClient()
|
||||
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
|
||||
val token = res.token
|
||||
if (res.success && token != null) {
|
||||
authTokenManager.setToken(token)
|
||||
OAuthPkceService.clear()
|
||||
currentScreen = AppScreen.Profile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (currentScreen) {
|
||||
is AppScreen.Home -> WelcomeScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onOpenPing = { AppScreen.Ping },
|
||||
onOpenLogin = {
|
||||
// Fallback to the local LoginScreen (Password Grant) if PKCE cannot be started
|
||||
currentScreen = AppScreen.Login
|
||||
},
|
||||
onOpenProfile = { currentScreen = AppScreen.Profile }
|
||||
)
|
||||
|
||||
is AppScreen.Login -> LoginScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onLoginSuccess = { currentScreen = AppScreen.Profile }
|
||||
)
|
||||
|
||||
is AppScreen.Ping -> PingScreen(viewModel = pingViewModel)
|
||||
is AppScreen.Profile -> AuthStatusScreen(
|
||||
authTokenManager = authTokenManager,
|
||||
onBackToHome = { currentScreen = AppScreen.Home }
|
||||
)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onOpenPing: () -> Unit,
|
||||
onOpenLogin: () -> Unit,
|
||||
onOpenProfile: () -> Unit
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Willkommen zur Meldestelle",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
// Auth info
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (authState.isAuthenticated) {
|
||||
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = onOpenProfile) { Text("Profil anzeigen") }
|
||||
} else {
|
||||
Text("Du bist nicht angemeldet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
|
||||
if (!authState.isAuthenticated) {
|
||||
Button(
|
||||
onClick = {
|
||||
// Try PKCE login (Authorization Code Flow w/ PKCE)
|
||||
scope.launch {
|
||||
try {
|
||||
val pkce = OAuthPkceService.startAuth()
|
||||
val url = OAuthPkceService.buildAuthorizeUrl(pkce, AppConstants.webRedirectUri())
|
||||
uriHandler.openUri(url)
|
||||
} catch (_: Throwable) {
|
||||
// Fallback: open the local Login screen (Password Grant)
|
||||
onOpenLogin()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Login") }
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { uriHandler.openUri(AppConstants.registerUrl()) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Registrieren (Keycloak)") }
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { uriHandler.openUri(AppConstants.loginUrl()) },
|
||||
modifier = Modifier.weight(1f)
|
||||
) { Text("Keycloak Login-Seite") }
|
||||
}
|
||||
|
||||
// Desktop Download Link
|
||||
OutlinedButton(
|
||||
onClick = { uriHandler.openUri(AppConstants.desktopDownloadUrl()) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) { Text("Desktop-App herunterladen") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthStatusScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onBackToHome: () -> Unit
|
||||
) {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("Profil / Status", style = MaterialTheme.typography.headlineMedium)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (authState.isAuthenticated) {
|
||||
Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = {
|
||||
authTokenManager.clearToken()
|
||||
onBackToHome()
|
||||
}) { Text("Abmelden") }
|
||||
} else {
|
||||
Text("Nicht angemeldet.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = onBackToHome) { Text("Zurück zur Startseite") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginScreen(
|
||||
authTokenManager: AuthTokenManager,
|
||||
onLoginSuccess: () -> Unit
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val api = remember { AuthApiClient() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("Benutzername") },
|
||||
singleLine = true,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Passwort") },
|
||||
singleLine = true,
|
||||
enabled = !isLoading,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
error = null
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
val res = api.login(username.trim(), password)
|
||||
val token = res.token
|
||||
if (res.success && token != null) {
|
||||
authTokenManager.setToken(token)
|
||||
isLoading = false
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
isLoading = false
|
||||
error = res.message ?: "Login fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isLoading && username.isNotBlank() && password.isNotBlank()
|
||||
) { Text(if (isLoading) "Bitte warten…" else "Login") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ComposeAppCommonTest {
|
||||
|
||||
@Test
|
||||
fun example() {
|
||||
assertEquals(3, 1 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
actual fun isDevelopmentMode(): Boolean =
|
||||
kotlinx.browser.window.location.hostname == "localhost"
|
||||
@@ -0,0 +1,67 @@
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.HTMLElement
|
||||
import at.mocode.clients.shared.di.initKoin
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.qualifier.named
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
console.log("[WebApp] main() entered")
|
||||
// Initialize DI (Koin) with shared modules + network module
|
||||
try {
|
||||
initKoin { modules(networkModule) }
|
||||
console.log("[WebApp] Koin initialized with networkModule")
|
||||
} catch (e: dynamic) {
|
||||
console.warn("[WebApp] Koin initialization warning:", e)
|
||||
}
|
||||
// Simple smoke request using DI apiClient
|
||||
try {
|
||||
val client = GlobalContext.get().get<HttpClient>(named("apiClient"))
|
||||
MainScope().launch {
|
||||
try {
|
||||
val resp: String = client.get("/api/ping/health").body()
|
||||
console.log("[WebApp] /api/ping/health → ", resp)
|
||||
} catch (e: dynamic) {
|
||||
console.warn("[WebApp] /api/ping/health failed:", e?.message ?: e)
|
||||
}
|
||||
}
|
||||
} catch (e: dynamic) {
|
||||
console.warn("[WebApp] Unable to resolve apiClient from Koin:", e)
|
||||
}
|
||||
fun startApp() {
|
||||
try {
|
||||
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
|
||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||
console.log("[WebApp] ComposeTarget exists? ", (root != null))
|
||||
ComposeViewport(root) {
|
||||
MainApp()
|
||||
}
|
||||
// Remove the static loading placeholder if present
|
||||
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
|
||||
console.log("[WebApp] ComposeViewport mounted, loading placeholder removed")
|
||||
} catch (e: Exception) {
|
||||
console.error("Failed to start Compose Web app", e)
|
||||
val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
|
||||
fallbackTarget.innerHTML =
|
||||
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
|
||||
}
|
||||
}
|
||||
|
||||
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded.
|
||||
val state = document.asDynamic().readyState as String?
|
||||
if (state == "interactive" || state == "complete") {
|
||||
console.log("[WebApp] DOM already ready (", state, ") → starting immediately")
|
||||
startApp()
|
||||
} else {
|
||||
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state)
|
||||
document.addEventListener("DOMContentLoaded", { startApp() })
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 560 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 667 KiB |
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meldestelle - Web</title>
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
</head>
|
||||
<body>
|
||||
<div id="ComposeTarget">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
<script>
|
||||
// Prefer explicit query param override (?apiBaseUrl=http://host:port),
|
||||
// then fall back to same-origin. This avoids Docker secrets and works with Nginx proxy.
|
||||
(function(){
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const override = params.get('apiBaseUrl');
|
||||
if (override) {
|
||||
globalThis.API_BASE_URL = override.replace(/\/$/, '');
|
||||
} else {
|
||||
globalThis.API_BASE_URL = window.location.origin.replace(/\/$/, '');
|
||||
}
|
||||
} catch (e) {
|
||||
globalThis.API_BASE_URL = 'http://localhost:8081';
|
||||
}
|
||||
})();
|
||||
// KMP bundle will read globalThis.API_BASE_URL in PlatformConfig.js
|
||||
</script>
|
||||
<script src="web-app.js"></script>
|
||||
<script>
|
||||
// Register Service Worker only in non-localhost environments
|
||||
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function(err){
|
||||
console.warn('ServiceWorker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Meldestelle",
|
||||
"short_name": "Melde",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #fafafa;
|
||||
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
|
||||
}
|
||||
|
||||
#ComposeTarget {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
const IS_DEV = self.location.hostname === 'localhost' || self.location.hostname === '127.0.0.1' || self.location.hostname === '::1';
|
||||
|
||||
const CACHE_NAME = 'meldestelle-cache-v2';
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/styles.css'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
if (IS_DEV) {
|
||||
// In dev, don't precache. Just activate the SW immediately.
|
||||
self.skipWaiting();
|
||||
return;
|
||||
}
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
if (IS_DEV) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
return;
|
||||
}
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (IS_DEV) {
|
||||
return; // don't interfere with dev server/HMR
|
||||
}
|
||||
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
|
||||
const isHttp = url.protocol === 'http:' || url.protocol === 'https:';
|
||||
const sameOrigin = url.origin === self.location.origin;
|
||||
const isExtension = url.protocol === 'chrome-extension:';
|
||||
const isHotUpdate = url.pathname.includes('hot-update');
|
||||
const isWebSocketUpgrade = req.headers.get('upgrade') === 'websocket';
|
||||
|
||||
// Ignore non-GET, cross-origin, browser extensions, HMR, and WebSocket upgrade requests
|
||||
if (req.method !== 'GET' || !isHttp || !sameOrigin || isExtension || isHotUpdate || isWebSocketUpgrade) {
|
||||
return; // Let the browser handle it
|
||||
}
|
||||
|
||||
if (req.mode === 'navigate') {
|
||||
// Network-first for navigation
|
||||
event.respondWith(
|
||||
fetch(req)
|
||||
.then((resp) => {
|
||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match('/index.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid noisy errors for favicon during dev/prod when missing
|
||||
if (url.pathname === '/favicon.ico') {
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
event.respondWith(
|
||||
caches.match(req).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(req)
|
||||
.then((resp) => {
|
||||
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match(req));
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
actual fun isDevelopmentMode(): Boolean =
|
||||
System.getProperty("development.mode", "false").toBoolean()
|
||||
@@ -0,0 +1,23 @@
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.clients.shared.di.initKoin
|
||||
import at.mocode.frontend.core.network.networkModule
|
||||
|
||||
fun main() = application {
|
||||
// Initialize DI (Koin) with shared modules + network module
|
||||
try {
|
||||
initKoin { modules(networkModule) }
|
||||
println("[DesktopApp] Koin initialized with networkModule")
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Koin initialization warning: ${e.message}")
|
||||
}
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Meldestelle - Desktop Development",
|
||||
state = WindowState(width = 1200.dp, height = 800.dp)
|
||||
) {
|
||||
MainApp()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.window.ComposeViewport
|
||||
import kotlinx.browser.document
|
||||
import org.w3c.dom.HTMLElement
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun main() {
|
||||
val root = document.getElementById("ComposeTarget") as HTMLElement
|
||||
ComposeViewport(root) {
|
||||
MainApp()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user