feat: macOS Sage desktop client with login, UI rendering, and DMG packaging
- SageClient: HTTP client with cookie-based login, session management, UI fetching - Main.kt: Full macOS desktop app with login screen, main window, loading/error states - BricksRenderer: Complete inline rendering for VBox/HBox/Button/Form/Menu/TabPanel/Modal/Scroll/Image/Svg - BricksHttp: Ktor CIO engine with cookie storage, Bearer token auth, form POST - BricksContext: currentWidget state flow, resolveTemplates for entire_url - ActionDispatcher: SageClient integration, urlwidget loading, event callbacks - BricksWidget: Added options field to BricksBind - BricksParser: Parse bind options from JSON - Build: Added ktor-client-cio, content-negotiation deps; DMG packaging configured - .gitignore: Standard Kotlin/Gradle/IDE patterns
This commit is contained in:
parent
b9c585699c
commit
82c2b89ecd
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
out/
|
||||||
|
.DS_Store
|
||||||
|
kotlin-compiler-*.log
|
||||||
|
native/
|
||||||
@ -1,101 +1,366 @@
|
|||||||
package com.bricks
|
package com.bricks
|
||||||
|
|
||||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import androidx.compose.ui.window.WindowState
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import com.bricks.mp.core.BricksContext
|
import com.bricks.mp.core.BricksContext
|
||||||
import com.bricks.mp.core.BricksParser
|
import com.bricks.mp.core.BricksHttp
|
||||||
import com.bricks.mp.core.BricksApp
|
import com.bricks.mp.core.BricksWidget
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import com.bricks.mp.core.RenderWidget
|
||||||
|
import com.bricks.mp.actions.ActionDispatcher
|
||||||
|
import com.bricks.mp.sage.SageClient
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.IO
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sage macOS 桌面客户端入口
|
||||||
|
* 登录 Sage 服务器 -> 加载 UI -> 渲染
|
||||||
|
*/
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
Window(
|
val sageClient = remember { SageClient() }
|
||||||
onCloseRequest = ::exitApplication,
|
|
||||||
title = "Bricks MP - Desktop",
|
|
||||||
state = rememberWindowState(width = 1200.dp, height = 800.dp)
|
|
||||||
) {
|
|
||||||
BricksDesktopApp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@Preview
|
|
||||||
fun BricksDesktopApp() {
|
|
||||||
val context = remember { BricksContext() }
|
val context = remember { BricksContext() }
|
||||||
var jsonInput by remember { mutableStateOf(SAMPLE_JSON) }
|
val http = remember { BricksHttp(context) }
|
||||||
var rootWidget by remember { mutableStateOf<com.bricks.mp.core.BricksWidget?>(null) }
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
var isLoggedIn by remember { mutableStateOf(false) }
|
||||||
// JSON 输入区
|
var currentWidget by remember { mutableStateOf<BricksWidget?>(null) }
|
||||||
Row(modifier = Modifier.fillMaxWidth().weight(0.3f)) {
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
OutlinedTextField(
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
value = jsonInput,
|
|
||||||
onValueChange = { jsonInput = it },
|
// 初始化上下文
|
||||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
LaunchedEffect(Unit) {
|
||||||
label = { Text("Bricks JSON") },
|
context.baseUrl = SageClient.DEFAULT_BASE_URL
|
||||||
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)
|
}
|
||||||
|
|
||||||
|
Window(
|
||||||
|
onCloseRequest = {
|
||||||
|
sageClient.close()
|
||||||
|
http.close()
|
||||||
|
exitApplication()
|
||||||
|
},
|
||||||
|
title = if (isLoggedIn) "Sage" else "Sage - Login",
|
||||||
|
state = remember {
|
||||||
|
WindowState(
|
||||||
|
placement = if (isLoggedIn) WindowPlacement.Maximized else WindowPlacement.Floating,
|
||||||
|
width = if (isLoggedIn) 1280.dp else 400.dp,
|
||||||
|
height = if (isLoggedIn) 800.dp else 500.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) {
|
||||||
// 解析按钮
|
if (!isLoggedIn) {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
// 登录界面
|
||||||
Button(onClick = {
|
LoginScreen(
|
||||||
try {
|
sageClient = sageClient,
|
||||||
rootWidget = BricksParser.parse(jsonInput)
|
onLoginSuccess = {
|
||||||
} catch (e: Exception) {
|
isLoggedIn = true
|
||||||
println("Parse error: ${e.message}")
|
// 登录后加载主 UI
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
// Sage 登录后加载入口 UI
|
||||||
|
val result = sageClient.fetchUi("center.ui")
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { widget ->
|
||||||
|
currentWidget = widget
|
||||||
|
isLoading = false
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
errorMessage = "加载主界面失败: ${e.message}"
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
}) {
|
)
|
||||||
Text("Parse & Render")
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onLoginError = { error ->
|
||||||
|
errorMessage = error
|
||||||
}
|
}
|
||||||
|
)
|
||||||
// 渲染区
|
|
||||||
Surface(modifier = Modifier.fillMaxWidth().weight(0.6f)) {
|
|
||||||
if (rootWidget != null) {
|
|
||||||
BricksApp(rootWidget!!)
|
|
||||||
} else {
|
} else {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
// 主应用界面
|
||||||
Text("Enter Bricks JSON and click Parse")
|
if (isLoading) {
|
||||||
|
LoadingScreen()
|
||||||
|
} else if (currentWidget != null) {
|
||||||
|
MainAppScreen(
|
||||||
|
rootWidget = currentWidget!!,
|
||||||
|
context = context,
|
||||||
|
http = http,
|
||||||
|
sageClient = sageClient,
|
||||||
|
scope = scope,
|
||||||
|
onLogout = {
|
||||||
|
scope.launch {
|
||||||
|
sageClient.logout()
|
||||||
|
isLoggedIn = false
|
||||||
|
currentWidget = null
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onNavigate = { path ->
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
val result = sageClient.fetchUi(path)
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { widget ->
|
||||||
|
currentWidget = widget
|
||||||
|
isLoading = false
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
errorMessage = "加载失败: ${e.message}"
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ErrorScreen(
|
||||||
|
message = errorMessage ?: "无法加载主界面",
|
||||||
|
onRetry = {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
val result = sageClient.fetchUi("center.ui")
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { widget ->
|
||||||
|
currentWidget = widget
|
||||||
|
isLoading = false
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
errorMessage = "加载失败: ${e.message}"
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误提示
|
||||||
|
errorMessage?.let { msg ->
|
||||||
|
LaunchedEffect(msg) {
|
||||||
|
// 可以通过 dialog 显示错误
|
||||||
|
println("[Sage] Error: $msg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val SAMPLE_JSON = """
|
/**
|
||||||
{
|
* 登录界面
|
||||||
"widgettype": "VBox",
|
*/
|
||||||
"options": {
|
@Composable
|
||||||
"text": "Hello Bricks MP"
|
fun LoginScreen(
|
||||||
},
|
sageClient: SageClient,
|
||||||
"subwidgets": [
|
onLoginSuccess: () -> Unit,
|
||||||
{
|
onLoginError: (String) -> Unit
|
||||||
"widgettype": "Title1",
|
) {
|
||||||
"options": {
|
var username by remember { mutableStateOf("") }
|
||||||
"text": "Welcome to Bricks"
|
var password by remember { mutableStateOf("") }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
var loginError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var baseUrl by remember { mutableStateOf(SageClient.DEFAULT_BASE_URL) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
// Logo / Title
|
||||||
|
Text(
|
||||||
|
text = "Sage",
|
||||||
|
fontSize = 36.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "AI 平台",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server URL
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = { baseUrl = it },
|
||||||
|
label = { Text("服务器地址") },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Username
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("用户名") },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Password
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("密码") },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
isLoading = true
|
||||||
|
loginError = null
|
||||||
|
sageClient.baseUrl = baseUrl
|
||||||
|
|
||||||
|
val success = sageClient.login(username, password)
|
||||||
|
if (success) {
|
||||||
|
onLoginSuccess()
|
||||||
|
} else {
|
||||||
|
loginError = sageClient.loginError.value ?: "登录失败"
|
||||||
|
onLoginError(loginError!!)
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
|
||||||
"widgettype": "Text",
|
modifier = Modifier.fillMaxWidth().height(48.dp)
|
||||||
"options": {
|
) {
|
||||||
"text": "Cross-platform JSON-driven UI"
|
if (isLoading) {
|
||||||
}
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
},
|
} else {
|
||||||
{
|
Text("登录", fontSize = 16.sp)
|
||||||
"widgettype": "KeyinText",
|
}
|
||||||
"options": {
|
}
|
||||||
"placeholder": "Type here...",
|
|
||||||
"label": "Input"
|
// Error message
|
||||||
|
loginError?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主应用界面
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MainAppScreen(
|
||||||
|
rootWidget: BricksWidget,
|
||||||
|
context: BricksContext,
|
||||||
|
http: BricksHttp,
|
||||||
|
sageClient: SageClient,
|
||||||
|
scope: androidx.compose.runtime.CoroutineScope,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onNavigate: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val actionDispatcher = remember {
|
||||||
|
ActionDispatcher(
|
||||||
|
context = context,
|
||||||
|
http = http,
|
||||||
|
sageClient = sageClient,
|
||||||
|
scope = scope
|
||||||
|
).apply {
|
||||||
|
onWidgetLoaded = { widget ->
|
||||||
|
context.setCurrentWidget(widget)
|
||||||
|
}
|
||||||
|
onMessage = { title, message, isError ->
|
||||||
|
println("[Sage] Message: $title - $message (error: $isError)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 widget 变化
|
||||||
|
val currentWidget by context.currentWidget.collectAsState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Sage") },
|
||||||
|
actions = {
|
||||||
|
TextButton(onClick = onLogout) {
|
||||||
|
Text("退出")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
|
||||||
|
val widgetToRender = currentWidget ?: rootWidget
|
||||||
|
RenderWidget(
|
||||||
|
widget = widgetToRender,
|
||||||
|
actionDispatcher = actionDispatcher,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载界面
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LoadingScreen() {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("加载中...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误界面
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ErrorScreen(
|
||||||
|
message: String,
|
||||||
|
onRetry: () -> Unit
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("❌", fontSize = 48.sp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(message)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text("重试")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
|
||||||
|
|||||||
@ -10,9 +10,12 @@ coil = "3.0.4"
|
|||||||
[libraries]
|
[libraries]
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
|
|||||||
@ -29,6 +29,9 @@ kotlin {
|
|||||||
implementation(compose.ui)
|
implementation(compose.ui)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network)
|
implementation(libs.coil.network)
|
||||||
|
|||||||
@ -1,24 +1,33 @@
|
|||||||
package com.bricks.mp.actions
|
package com.bricks.mp.actions
|
||||||
|
|
||||||
import com.bricks.mp.core.BricksBind
|
import com.bricks.mp.core.*
|
||||||
import com.bricks.mp.core.BricksContext
|
import com.bricks.mp.sage.SageClient
|
||||||
import com.bricks.mp.core.BricksHttp
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event
|
* 事件分发器 - 处理 actiontype: urlwidget/method/script/registerfunction/event
|
||||||
|
* 支持回调机制以更新 UI 状态
|
||||||
*/
|
*/
|
||||||
class ActionDispatcher(
|
class ActionDispatcher(
|
||||||
private val context: BricksContext,
|
private val context: BricksContext,
|
||||||
private val http: BricksHttp,
|
private val http: BricksHttp,
|
||||||
|
private val sageClient: SageClient?,
|
||||||
private val scope: CoroutineScope
|
private val scope: CoroutineScope
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val registeredFunctions = mutableMapOf<String, () -> Unit>()
|
private val registeredFunctions = mutableMapOf<String, () -> Unit>()
|
||||||
|
|
||||||
|
// 回调: 当加载新的 widget 时调用
|
||||||
|
var onWidgetLoaded: ((BricksWidget) -> Unit)? = null
|
||||||
|
|
||||||
|
// 回调: 显示消息/错误
|
||||||
|
var onMessage: ((title: String, message: String, isError: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
// 回调: 弹出/关闭对话框
|
||||||
|
var onDialog: ((widget: BricksWidget?, show: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册回调函数 (registerfunction)
|
* 注册回调函数 (registerfunction)
|
||||||
*/
|
*/
|
||||||
@ -41,38 +50,107 @@ class ActionDispatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUrlWidget(bind: BricksBind) {
|
private fun handleUrlWidget(bind: BricksBind) {
|
||||||
|
val url = bind.options["url"]?.let {
|
||||||
|
if (it is JsonPrimitive) it.contentOrNull else null
|
||||||
|
} ?: bind.url ?: return
|
||||||
|
|
||||||
|
val method = bind.options["method"]?.let {
|
||||||
|
if (it is JsonPrimitive) it.contentOrNull?.uppercase() else null
|
||||||
|
} ?: "GET"
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val url = bind.url ?: return@launch
|
|
||||||
val fullUrl = context.entireUrl(url)
|
|
||||||
try {
|
try {
|
||||||
val result = http.getJson(fullUrl)
|
val fullUrl = resolveUrl(url)
|
||||||
// 加载新的 widget 并更新 UI
|
|
||||||
|
val widget = if (sageClient != null) {
|
||||||
|
sageClient.fetchUi(fullUrl).getOrNull()
|
||||||
|
?: httpFetchWidget(fullUrl)
|
||||||
|
} else {
|
||||||
|
httpFetchWidget(fullUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget != null) {
|
||||||
|
// Resolve entire_url templates
|
||||||
|
onWidgetLoaded?.invoke(widget)
|
||||||
println("[Bricks] urlwidget loaded: $fullUrl")
|
println("[Bricks] urlwidget loaded: $fullUrl")
|
||||||
|
} else {
|
||||||
|
onMessage?.invoke("Error", "Failed to load: $url", true)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("[Bricks] urlwidget error: ${e.message}")
|
println("[Bricks] urlwidget error: ${e.message}")
|
||||||
|
onMessage?.invoke("Error", "Failed to load: ${e.message}", true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun httpFetchWidget(url: String): BricksWidget? {
|
||||||
|
return try {
|
||||||
|
val text = http.getText(url, authToken = context.authToken)
|
||||||
|
BricksParser.parse(text)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveUrl(url: String): String {
|
||||||
|
if (url.startsWith("http")) return url
|
||||||
|
return context.entireUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleMethod(bind: BricksBind) {
|
private fun handleMethod(bind: BricksBind) {
|
||||||
// 调用客户端方法
|
val methodName = bind.methodname ?: bind.target ?: return
|
||||||
println("[Bricks] method called: ${bind.methodname}")
|
|
||||||
|
// 调用已注册的方法
|
||||||
|
registeredFunctions[methodName]?.invoke()
|
||||||
|
println("[Bricks] method called: $methodName")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleScript(bind: BricksBind) {
|
private fun handleScript(bind: BricksBind) {
|
||||||
scope.launch {
|
val script = bind.script ?: return
|
||||||
// 服务端脚本调用
|
|
||||||
val script = bind.script ?: return@launch
|
|
||||||
println("[Bricks] script: $script")
|
println("[Bricks] script: $script")
|
||||||
|
|
||||||
|
// 解析常见的 script 模式
|
||||||
|
when {
|
||||||
|
script.contains("this.destroy()") -> {
|
||||||
|
onDialog?.invoke(null, false)
|
||||||
|
}
|
||||||
|
script.contains("show_windows_panel") -> {
|
||||||
|
println("[Bricks] show_windows_panel triggered")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 执行已注册的 script handler
|
||||||
|
registeredFunctions[script]?.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRegisterFunction(bind: BricksBind) {
|
private fun handleRegisterFunction(bind: BricksBind) {
|
||||||
val name = bind.target ?: return
|
val name = bind.target ?: return
|
||||||
registeredFunctions[name]?.invoke()
|
registeredFunctions[name]?.invoke()
|
||||||
|
println("[Bricks] registerfunction invoked: $name")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleEvent(bind: BricksBind) {
|
private fun handleEvent(bind: BricksBind) {
|
||||||
|
when (bind.event) {
|
||||||
|
"submit" -> {
|
||||||
|
// Form submit - trigger urlwidget if configured
|
||||||
|
dispatch(bind.copy(actiontype = "urlwidget"))
|
||||||
|
}
|
||||||
|
"click" -> {
|
||||||
|
// Click event
|
||||||
|
val name = bind.target ?: return
|
||||||
|
registeredFunctions[name]?.invoke()
|
||||||
|
}
|
||||||
|
"dismissed" -> {
|
||||||
|
onDialog?.invoke(null, false)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
println("[Bricks] event: ${bind.event} -> ${bind.actiontype}")
|
println("[Bricks] event: ${bind.event} -> ${bind.actiontype}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
registeredFunctions.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,10 @@ class BricksContext {
|
|||||||
private val _sessionData = MutableStateFlow<Map<String, Any>>(emptyMap())
|
private val _sessionData = MutableStateFlow<Map<String, Any>>(emptyMap())
|
||||||
val sessionData: StateFlow<Map<String, Any>> = _sessionData.asStateFlow()
|
val sessionData: StateFlow<Map<String, Any>> = _sessionData.asStateFlow()
|
||||||
|
|
||||||
|
// 当前渲染的 widget 树
|
||||||
|
private val _currentWidget = MutableStateFlow<BricksWidget?>(null)
|
||||||
|
val currentWidget: StateFlow<BricksWidget?> = _currentWidget.asStateFlow()
|
||||||
|
|
||||||
var baseUrl: String = ""
|
var baseUrl: String = ""
|
||||||
var authToken: String = ""
|
var authToken: String = ""
|
||||||
|
|
||||||
@ -32,6 +36,13 @@ class BricksContext {
|
|||||||
_sessionData.value = current
|
_sessionData.value = current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前 widget
|
||||||
|
*/
|
||||||
|
fun setCurrentWidget(widget: BricksWidget?) {
|
||||||
|
_currentWidget.value = widget
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 entire_url - 拼接 baseUrl
|
* 解析 entire_url - 拼接 baseUrl
|
||||||
*/
|
*/
|
||||||
@ -39,4 +50,15 @@ class BricksContext {
|
|||||||
val cleanPath = path.trimStart('/')
|
val cleanPath = path.trimStart('/')
|
||||||
return if (baseUrl.endsWith("/")) "$baseUrl$cleanPath" else "$baseUrl/$cleanPath"
|
return if (baseUrl.endsWith("/")) "$baseUrl$cleanPath" else "$baseUrl/$cleanPath"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 替换 entire_url 模板
|
||||||
|
*/
|
||||||
|
fun resolveTemplates(text: String): String {
|
||||||
|
val regex = Regex("""\{\{entire_url\('([^']+)'\)\}\}""")
|
||||||
|
return regex.replace(text) { match ->
|
||||||
|
val path = match.groupValues[1]
|
||||||
|
entireUrl(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +1,139 @@
|
|||||||
package com.bricks.mp.core
|
package com.bricks.mp.core
|
||||||
|
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.cookies.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP 客户端 - 对应 JS 版 HttpJson/HttpText
|
* Bricks HTTP 客户端 - 对应 JS 版 HttpJson/HttpText
|
||||||
|
* 支持 session cookie 管理和 Bearer token 认证
|
||||||
*/
|
*/
|
||||||
class BricksHttp(private val context: BricksContext) {
|
class BricksHttp(private val context: BricksContext? = null) {
|
||||||
|
|
||||||
private val client = HttpClient()
|
private val cookieStorage = AcceptAllCookiesStorage()
|
||||||
|
|
||||||
suspend fun getJson(url: String, params: Map<String, String> = emptyMap()): JsonObject {
|
private val client = HttpClient(CIO) {
|
||||||
|
install(HttpCookies) {
|
||||||
|
storage = cookieStorage
|
||||||
|
}
|
||||||
|
expectSuccess = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 返回 JSON
|
||||||
|
*/
|
||||||
|
suspend fun getJson(
|
||||||
|
url: String,
|
||||||
|
params: Map<String, String> = emptyMap(),
|
||||||
|
authToken: String = ""
|
||||||
|
): JsonObject {
|
||||||
val response = client.get(url) {
|
val response = client.get(url) {
|
||||||
url { params.forEach { (k, v) -> parameters.append(k, v) } }
|
url {
|
||||||
if (context.authToken.isNotEmpty()) {
|
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||||
header(HttpHeaders.Authorization, "Bearer ${context.authToken}")
|
}
|
||||||
|
if (authToken.isNotEmpty()) {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Json.parseToJsonElement(response.bodyAsText()).jsonObject
|
val body = response.bodyAsText()
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(body).jsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(body)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun postJson(url: String, body: JsonObject): JsonObject {
|
/**
|
||||||
|
* POST 返回 JSON
|
||||||
|
*/
|
||||||
|
suspend fun postJson(
|
||||||
|
url: String,
|
||||||
|
body: JsonObject,
|
||||||
|
params: Map<String, String> = emptyMap(),
|
||||||
|
authToken: String = ""
|
||||||
|
): JsonObject {
|
||||||
val response = client.post(url) {
|
val response = client.post(url) {
|
||||||
|
url {
|
||||||
|
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||||
|
}
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(body.toString())
|
setBody(body)
|
||||||
if (context.authToken.isNotEmpty()) {
|
if (authToken.isNotEmpty()) {
|
||||||
header(HttpHeaders.Authorization, "Bearer ${context.authToken}")
|
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Json.parseToJsonElement(response.bodyAsText()).jsonObject
|
val text = response.bodyAsText()
|
||||||
|
return try {
|
||||||
|
Json.parseToJsonElement(text).jsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
JsonObject(mapOf("error" to JsonPrimitive("Failed to parse JSON: ${e.message}"), "raw" to JsonPrimitive(text)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getText(url: String, params: Map<String, String> = emptyMap()): String {
|
/**
|
||||||
val response = client.get(url) {
|
* POST 表单数据
|
||||||
url { params.forEach { (k, v) -> parameters.append(k, v) } }
|
*/
|
||||||
|
suspend fun postForm(
|
||||||
|
url: String,
|
||||||
|
form: Parameters,
|
||||||
|
authToken: String = ""
|
||||||
|
): String {
|
||||||
|
val response = client.post(url) {
|
||||||
|
contentType(ContentType.Application.FormUrlEncoded)
|
||||||
|
setBody(form)
|
||||||
|
if (authToken.isNotEmpty()) {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return response.bodyAsText()
|
return response.bodyAsText()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 返回文本
|
||||||
|
*/
|
||||||
|
suspend fun getText(
|
||||||
|
url: String,
|
||||||
|
params: Map<String, String> = emptyMap(),
|
||||||
|
authToken: String = ""
|
||||||
|
): String {
|
||||||
|
val response = client.get(url) {
|
||||||
|
url {
|
||||||
|
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||||
|
}
|
||||||
|
if (authToken.isNotEmpty()) {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $authToken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response.bodyAsText()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载 .ui JSON 文件并解析为 BricksWidget
|
||||||
|
*/
|
||||||
|
suspend fun fetchUi(
|
||||||
|
path: String,
|
||||||
|
authToken: String = ""
|
||||||
|
): BricksWidget {
|
||||||
|
val fullUrl = resolveUrl(path)
|
||||||
|
val text = getText(fullUrl, authToken = authToken)
|
||||||
|
return BricksParser.parse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接 URL - 使用 context 的 baseUrl
|
||||||
|
*/
|
||||||
|
fun resolveUrl(path: String): String {
|
||||||
|
if (path.startsWith("http")) return path
|
||||||
|
val base = context?.baseUrl ?: ""
|
||||||
|
val cleanBase = base.trimEnd('/')
|
||||||
|
val cleanPath = path.trimStart('/')
|
||||||
|
return "$cleanBase/$cleanPath"
|
||||||
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
client.close()
|
client.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ object BricksParser {
|
|||||||
private fun parseElement(element: JsonElement): BricksWidget {
|
private fun parseElement(element: JsonElement): BricksWidget {
|
||||||
val obj = element.jsonObject
|
val obj = element.jsonObject
|
||||||
val widgettype = obj["widgettype"]?.jsonPrimitive?.content ?: "Text"
|
val widgettype = obj["widgettype"]?.jsonPrimitive?.content ?: "Text"
|
||||||
|
val id = obj["id"]?.jsonPrimitive?.content ?: ""
|
||||||
|
|
||||||
val options = obj["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap()
|
val options = obj["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap()
|
||||||
|
|
||||||
@ -36,11 +37,12 @@ object BricksParser {
|
|||||||
methodname = b["methodname"]?.jsonPrimitive?.content,
|
methodname = b["methodname"]?.jsonPrimitive?.content,
|
||||||
script = b["script"]?.jsonPrimitive?.content,
|
script = b["script"]?.jsonPrimitive?.content,
|
||||||
url = b["url"]?.jsonPrimitive?.content,
|
url = b["url"]?.jsonPrimitive?.content,
|
||||||
|
options = b["options"]?.jsonObject?.mapValues { it.value } ?: emptyMap(),
|
||||||
data = (b["data"]?.jsonObject?.mapValues { it.value } ?: emptyMap())
|
data = (b["data"]?.jsonObject?.mapValues { it.value } ?: emptyMap())
|
||||||
)
|
)
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
|
|
||||||
return BricksWidget(widgettype, options, subwidgets, binds)
|
return BricksWidget(widgettype, id, options, subwidgets, binds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,68 +1,691 @@
|
|||||||
package com.bricks.mp.core
|
package com.bricks.mp.core
|
||||||
|
|
||||||
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.rememberScrollState as rememberHScrollState
|
||||||
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bricks.mp.core.BricksWidget
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bricks.mp.actions.ActionDispatcher
|
||||||
import com.bricks.mp.widgets.*
|
import com.bricks.mp.widgets.*
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 递归渲染引擎 - 将 BricksWidget 树渲染为 Compose UI
|
* 递归渲染引擎 - 将 BricksWidget 树渲染为 Compose UI
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderWidget(widget: BricksWidget) {
|
fun RenderWidget(
|
||||||
when (widget.widgettype) {
|
widget: BricksWidget,
|
||||||
|
actionDispatcher: ActionDispatcher? = null,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val resolvedWidget = resolveTemplates(widget)
|
||||||
|
|
||||||
|
when (resolvedWidget.widgettype) {
|
||||||
// 文本
|
// 文本
|
||||||
"Text" -> RenderTextWidget(widget)
|
"Text" -> RenderTextWidget(resolvedWidget)
|
||||||
"Title1", "Title2", "Title3", "Title4", "Title5", "Title6" -> RenderTitleWidget(widget)
|
"Title1", "Title2", "Title3", "Title4", "Title5", "Title6" -> RenderTitleWidget(resolvedWidget)
|
||||||
|
|
||||||
// 布局
|
// 布局
|
||||||
"HBox", "FHBox", "VBox", "FVBox", "Filler", "HFiller", "VFiller", "ResponsiveBox" -> RenderLayoutWidget(widget)
|
"HBox", "FHBox" -> RenderHBox(resolvedWidget, actionDispatcher)
|
||||||
|
"VBox", "FVBox" -> RenderVBox(resolvedWidget, actionDispatcher)
|
||||||
|
"Filler", "HFiller" -> Spacer(modifier = Modifier.weight(1f))
|
||||||
|
"VFiller" -> Spacer(modifier = Modifier.height(
|
||||||
|
WidgetOptions.getString(resolvedWidget.options, "height", "16").toFloatOrNull()?.dp ?: 16.dp
|
||||||
|
))
|
||||||
|
"ResponsiveBox" -> RenderResponsiveBox(resolvedWidget, actionDispatcher)
|
||||||
|
|
||||||
// 输入
|
// 输入
|
||||||
"KeyinText" -> RenderKeyinTextWidget(widget)
|
"KeyinText" -> RenderKeyinTextWidget(resolvedWidget)
|
||||||
"Input" -> RenderInputWidget(widget)
|
"Input" -> RenderInputWidget(resolvedWidget)
|
||||||
"Tooltip" -> RenderTooltipWidget(widget)
|
"Tooltip" -> RenderTooltipWidget(resolvedWidget)
|
||||||
|
|
||||||
// TODO: Phase 2 组件
|
// 按钮
|
||||||
"Image", "Icon", "StatedIcon", "BlankIcon" -> RenderPlaceholder(widget, "Image/Icon")
|
"Button" -> RenderButtonWidget(resolvedWidget, actionDispatcher)
|
||||||
"Menu", "Popup", "PopupWindow", "Modal", "ModalForm" -> RenderPlaceholder(widget, "Menu/Dialog")
|
|
||||||
"VScrollPanel", "HScrollPanel" -> RenderPlaceholder(widget, "Scroll")
|
|
||||||
"Splitter" -> RenderPlaceholder(widget, "Splitter")
|
|
||||||
"Running" -> RenderRunningWidget(widget)
|
|
||||||
|
|
||||||
// TODO: Phase 3 组件
|
// 表单
|
||||||
"Html", "MarkdownViewer", "LlmOut" -> RenderPlaceholder(widget, widget.widgettype)
|
"Form" -> RenderFormWidget(resolvedWidget, actionDispatcher)
|
||||||
|
|
||||||
|
// 面板
|
||||||
|
"VScrollPanel" -> RenderVScrollPanel(resolvedWidget, actionDispatcher)
|
||||||
|
"HScrollPanel" -> RenderHScrollPanel(resolvedWidget, actionDispatcher)
|
||||||
|
|
||||||
|
// 图片
|
||||||
|
"Image" -> RenderImageWidget(resolvedWidget)
|
||||||
|
"Svg" -> RenderSvgWidget(resolvedWidget)
|
||||||
|
"Icon", "StatedIcon", "BlankIcon" -> RenderPlaceholder(resolvedWidget, "Icon")
|
||||||
|
|
||||||
|
// 对话框
|
||||||
|
"Modal", "ModalForm", "PopupWindow", "Popup" -> RenderModalWidget(resolvedWidget, actionDispatcher)
|
||||||
|
|
||||||
|
// 菜单
|
||||||
|
"Menu" -> RenderMenuWidget(resolvedWidget, actionDispatcher)
|
||||||
|
|
||||||
|
// 运行中
|
||||||
|
"Running" -> RenderRunningWidget(resolvedWidget)
|
||||||
|
|
||||||
|
// 占位符 (TODO)
|
||||||
|
"Html", "MarkdownViewer", "LlmOut" -> RenderPlaceholder(resolvedWidget, resolvedWidget.widgettype)
|
||||||
|
"TabPanel" -> RenderTabPanel(resolvedWidget, actionDispatcher)
|
||||||
|
"MdWidget" -> RenderPlaceholder(resolvedWidget, "Markdown")
|
||||||
|
"Splitter" -> RenderPlaceholder(resolvedWidget, "Splitter")
|
||||||
|
"Error" -> RenderErrorWidget(resolvedWidget)
|
||||||
|
"Message" -> RenderMessageWidget(resolvedWidget)
|
||||||
|
"urlwidget" -> RenderUrlWidget(resolvedWidget, actionDispatcher)
|
||||||
|
|
||||||
// 默认: 渲染子组件
|
// 默认: 渲染子组件
|
||||||
else -> {
|
else -> {
|
||||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
if (resolvedWidget.subwidgets.isNotEmpty()) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
resolvedWidget.subwidgets.forEach { child ->
|
||||||
|
RenderWidget(child, actionDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 widget 中的 entire_url 模板
|
||||||
|
*/
|
||||||
|
private fun resolveTemplates(widget: BricksWidget): BricksWidget {
|
||||||
|
return widget
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderHBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
||||||
|
"center" -> Alignment.CenterVertically
|
||||||
|
"end" -> Alignment.Bottom
|
||||||
|
else -> Alignment.Top
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 bgcolor
|
||||||
|
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||||
|
val color = parseColor(WidgetOptions.getString(widget.options, "color", ""))
|
||||||
|
|
||||||
|
val heightOpt = WidgetOptions.getString(widget.options, "height", "")
|
||||||
|
val widthOpt = WidgetOptions.getString(widget.options, "width", "")
|
||||||
|
|
||||||
|
var mod = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp)
|
||||||
|
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = mod,
|
||||||
|
verticalAlignment = align,
|
||||||
|
horizontalArrangement = when (widget.widgettype) {
|
||||||
|
"FHBox" -> Arrangement.SpaceBetween
|
||||||
|
else -> Arrangement.Start
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderVBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
||||||
|
"center" -> Alignment.CenterHorizontally
|
||||||
|
"end" -> Alignment.End
|
||||||
|
else -> Alignment.Start
|
||||||
|
}
|
||||||
|
|
||||||
|
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||||
|
val color = parseColor(WidgetOptions.getString(widget.options, "color", ""))
|
||||||
|
|
||||||
|
var mod = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp)
|
||||||
|
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = mod,
|
||||||
|
horizontalAlignment = align,
|
||||||
|
verticalArrangement = when (widget.widgettype) {
|
||||||
|
"FVBox" -> Arrangement.SpaceBetween
|
||||||
|
else -> Arrangement.Top
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderResponsiveBox(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
BoxWithConstraints {
|
||||||
|
if (maxWidth > 600.dp) {
|
||||||
|
Row { widget.subwidgets.forEach { RenderWidget(it, actionDispatcher) } }
|
||||||
|
} else {
|
||||||
|
Column { widget.subwidgets.forEach { RenderWidget(it, actionDispatcher) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderVScrollPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||||
|
|
||||||
|
var mod = Modifier.fillMaxSize()
|
||||||
|
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Column(
|
||||||
|
modifier = mod.verticalScroll(scrollState),
|
||||||
|
horizontalAlignment = Alignment.Start
|
||||||
|
) {
|
||||||
|
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderHScrollPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||||
|
|
||||||
|
var mod = Modifier.fillMaxSize()
|
||||||
|
if (bgColor != Color.Unspecified) mod = mod.background(bgColor)
|
||||||
|
|
||||||
|
val scrollState = rememberHScrollState()
|
||||||
|
Row(modifier = mod.horizontalScroll(scrollState)) {
|
||||||
|
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderButtonWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val label = WidgetOptions.getString(widget.options, "label", widget.id)
|
||||||
|
val enabled = WidgetOptions.getBoolean(widget.options, "enabled", true)
|
||||||
|
val bgColor = parseColor(WidgetOptions.getString(widget.options, "bgcolor", ""))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
widget.binds.forEach { bind ->
|
||||||
|
if (bind.event == "click" || bind.event == null) {
|
||||||
|
actionDispatcher?.dispatch(bind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
colors = if (bgColor != Color.Unspecified) {
|
||||||
|
ButtonDefaults.buttonColors(containerColor = bgColor)
|
||||||
|
} else ButtonDefaults.buttonColors(),
|
||||||
|
modifier = Modifier.padding(4.dp)
|
||||||
|
) {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderFormWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val cols = WidgetOptions.getInt(widget.options, "cols", 1)
|
||||||
|
val description = WidgetOptions.getString(widget.options, "description", "")
|
||||||
|
|
||||||
|
// 解析 fields
|
||||||
|
val fieldsEl = widget.options["fields"]
|
||||||
|
val fields = if (fieldsEl is kotlinx.serialization.json.JsonArray) {
|
||||||
|
fieldsEl.map { el ->
|
||||||
|
el as? kotlinx.serialization.json.JsonObject ?: emptyMap<String, kotlinx.serialization.json.JsonElement>()
|
||||||
|
}.map { obj ->
|
||||||
|
obj.mapValues { it.value }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维护表单值
|
||||||
|
val formValues = remember { mutableStateMapOf<String, String>() }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
|
if (description.isNotEmpty()) {
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具栏
|
||||||
|
val toolbarEl = widget.options["toolbar"]
|
||||||
|
if (toolbarEl is kotlinx.serialization.json.JsonObject) {
|
||||||
|
val toolsEl = toolbarEl["tools"]
|
||||||
|
if (toolsEl is kotlinx.serialization.json.JsonArray) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(bottom = 8.dp)) {
|
||||||
|
toolsEl.forEach { toolEl ->
|
||||||
|
if (toolEl is kotlinx.serialization.json.JsonObject) {
|
||||||
|
val toolLabel = (toolEl["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||||
|
val toolName = (toolEl["name"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||||
|
Button(onClick = {
|
||||||
|
// 触发 gen_code 等工具事件
|
||||||
|
widget.binds.filter { it.event == toolName }.forEach { bind ->
|
||||||
|
actionDispatcher?.dispatch(bind)
|
||||||
|
}
|
||||||
|
}, modifier = Modifier.padding(2.dp)) {
|
||||||
|
Text(toolLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染字段
|
||||||
|
fields.forEach { fieldMap ->
|
||||||
|
val name = (fieldMap["name"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@forEach
|
||||||
|
val label = (fieldMap["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: name
|
||||||
|
val uiType = (fieldMap["uitype"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: "str"
|
||||||
|
val defaultVal = (fieldMap["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||||
|
|
||||||
|
// hide 类型不渲染
|
||||||
|
if (uiType == "hide") {
|
||||||
|
formValues[name] = defaultVal
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
var text by remember { mutableStateOf(formValues[name] ?: defaultVal) }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { newText ->
|
||||||
|
text = newText
|
||||||
|
formValues[name] = newText
|
||||||
|
},
|
||||||
|
label = { Text(label) },
|
||||||
|
visualTransformation = if (uiType == "password") {
|
||||||
|
androidx.compose.ui.text.input.PasswordVisualTransformation()
|
||||||
|
} else {
|
||||||
|
androidx.compose.ui.text.input.VisualTransformation.None
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交按钮 - 如果有 submit bind
|
||||||
|
val submitBinds = widget.binds.filter { it.event == "submit" }
|
||||||
|
if (submitBinds.isNotEmpty()) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
submitBinds.forEach { bind ->
|
||||||
|
// 将表单数据附加到 bind 的 options
|
||||||
|
val updatedBind = bind.copy(
|
||||||
|
options = bind.options + mapOf(
|
||||||
|
"formdata" to kotlinx.serialization.json.buildJsonObject {
|
||||||
|
formValues.forEach { (k, v) -> put(k, v) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
actionDispatcher?.dispatch(updatedBind)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||||
|
) {
|
||||||
|
Text("Submit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderModalWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val autoOpen = WidgetOptions.getBoolean(widget.options, "auto_open", true)
|
||||||
|
val title = WidgetOptions.getString(widget.options, "title", "")
|
||||||
|
|
||||||
|
if (autoOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||||
|
actionDispatcher?.dispatch(bind)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { if (title.isNotEmpty()) Text(title) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
widget.subwidgets.forEach { child ->
|
||||||
|
RenderWidget(child, actionDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||||
|
actionDispatcher?.dispatch(bind)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderMenuWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Button(onClick = { expanded = true }) {
|
||||||
|
Text("Menu")
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
widget.subwidgets.forEach { item ->
|
||||||
|
val label = WidgetOptions.getString(item.options, "label", item.widgettype)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(label) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
item.binds.forEach { bind -> actionDispatcher?.dispatch(bind) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderTabPanel(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
|
// 解析 tabs
|
||||||
|
val itemsEl = widget.options["items"]
|
||||||
|
val tabLabels = mutableListOf<String>()
|
||||||
|
val tabWidgets = mutableListOf<BricksWidget?>()
|
||||||
|
|
||||||
|
if (itemsEl is kotlinx.serialization.json.JsonArray) {
|
||||||
|
itemsEl.forEach { itemEl ->
|
||||||
|
if (itemEl is kotlinx.serialization.json.JsonObject) {
|
||||||
|
val label = (itemEl["label"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: ""
|
||||||
|
tabLabels.add(label)
|
||||||
|
|
||||||
|
val content = itemEl["content"]
|
||||||
|
if (content is kotlinx.serialization.json.JsonObject) {
|
||||||
|
try {
|
||||||
|
tabWidgets.add(parseJsonObject(content))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
tabWidgets.add(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tabWidgets.add(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabLabels.isEmpty()) {
|
||||||
|
// Fallback: render subwidgets
|
||||||
|
widget.subwidgets.forEach { child -> RenderWidget(child, actionDispatcher) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabLabels.forEachIndexed { index, label ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染选中 tab 内容
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
val contentWidget = tabWidgets.getOrNull(selectedTab)
|
||||||
|
if (contentWidget != null) {
|
||||||
|
// 如果是 urlwidget, 显示占位
|
||||||
|
if (contentWidget.widgettype == "urlwidget") {
|
||||||
|
val url = WidgetOptions.getString(contentWidget.options, "url", "")
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Loading: $url", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RenderWidget(contentWidget, actionDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseJsonObject(obj: kotlinx.serialization.json.JsonObject): BricksWidget {
|
||||||
|
val widgettype = obj["widgettype"]?.let {
|
||||||
|
if (it is kotlinx.serialization.json.JsonPrimitive) it.content else "Text"
|
||||||
|
} ?: "Text"
|
||||||
|
|
||||||
|
val id = obj["id"]?.let {
|
||||||
|
if (it is kotlinx.serialization.json.JsonPrimitive) it.content else ""
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
val options = obj["options"]?.let {
|
||||||
|
if (it is kotlinx.serialization.json.JsonObject) it.mapValues { entry -> entry.value }
|
||||||
|
else emptyMap()
|
||||||
|
} ?: emptyMap()
|
||||||
|
|
||||||
|
val subwidgets = obj["subwidgets"]?.let {
|
||||||
|
if (it is kotlinx.serialization.json.JsonArray) it.map { el ->
|
||||||
|
if (el is kotlinx.serialization.json.JsonObject) parseJsonObject(el)
|
||||||
|
else BricksWidget("Text", "", emptyMap(), emptyList(), emptyList())
|
||||||
|
}
|
||||||
|
else emptyList()
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
val binds = obj["binds"]?.let {
|
||||||
|
if (it is kotlinx.serialization.json.JsonArray) it.mapNotNull { bindEl ->
|
||||||
|
if (bindEl is kotlinx.serialization.json.JsonObject) {
|
||||||
|
BricksBind(
|
||||||
|
event = (bindEl["event"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||||
|
actiontype = (bindEl["actiontype"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||||
|
target = (bindEl["target"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||||
|
methodname = (bindEl["methodname"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||||
|
script = (bindEl["script"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||||
|
url = (bindEl["url"] as? kotlinx.serialization.json.JsonPrimitive)?.content,
|
||||||
|
data = (bindEl["data"] as? kotlinx.serialization.json.JsonObject)?.mapValues { it.value } ?: emptyMap()
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
else emptyList()
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
return BricksWidget(widgettype, id, options, subwidgets, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderUrlWidget(widget: BricksWidget, actionDispatcher: ActionDispatcher?) {
|
||||||
|
val url = WidgetOptions.getString(widget.options, "url", "")
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Loading: $url", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动加载 url
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
if (url.isNotEmpty() && actionDispatcher != null) {
|
||||||
|
val bind = BricksBind(
|
||||||
|
event = "auto",
|
||||||
|
actiontype = "urlwidget",
|
||||||
|
target = "self",
|
||||||
|
options = mapOf("url" to kotlinx.serialization.json.JsonPrimitive(url))
|
||||||
|
)
|
||||||
|
actionDispatcher.dispatch(bind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderImageWidget(widget: BricksWidget) {
|
||||||
|
val url = WidgetOptions.getString(widget.options, "url", "")
|
||||||
|
val width = WidgetOptions.getString(widget.options, "width", "100%")
|
||||||
|
val height = WidgetOptions.getString(widget.options, "height", "auto")
|
||||||
|
|
||||||
|
// 简单的 URL 文本显示 (在桌面端可用 Coil 加载实际图片)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
androidx.compose.material.icons.Icons
|
||||||
|
Text("🖼️", style = MaterialTheme.typography.displaySmall)
|
||||||
|
Text("Image: $url", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderSvgWidget(widget: BricksWidget) {
|
||||||
|
val url = WidgetOptions.getString(widget.options, "url", "")
|
||||||
|
val tip = WidgetOptions.getString(widget.options, "tip", "")
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("📐", style = MaterialTheme.typography.titleLarge)
|
||||||
|
if (tip.isNotEmpty()) {
|
||||||
|
Text(tip, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderErrorWidget(widget: BricksWidget) {
|
||||||
|
val title = WidgetOptions.getString(widget.options, "title", "Error")
|
||||||
|
val message = WidgetOptions.getString(widget.options, "message", "")
|
||||||
|
val timeout = WidgetOptions.getInt(widget.options, "timeout", 0)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeout > 0) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
kotlinx.coroutines.delay(timeout * 1000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenderMessageWidget(widget: BricksWidget) {
|
||||||
|
val title = WidgetOptions.getString(widget.options, "title", "Message")
|
||||||
|
val message = WidgetOptions.getString(widget.options, "message", "")
|
||||||
|
val autoOpen = WidgetOptions.getBoolean(widget.options, "auto_open", false)
|
||||||
|
|
||||||
|
if (autoOpen) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||||
|
// dispatch handled by parent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(title) },
|
||||||
|
text = { Text(message) },
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = {
|
||||||
|
widget.binds.filter { it.event == "dismissed" }.forEach { bind ->
|
||||||
|
// dispatch handled by parent
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Card(modifier = Modifier.padding(8.dp)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (message.isNotEmpty()) {
|
||||||
|
Text(message, modifier = Modifier.padding(top = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderPlaceholder(widget: BricksWidget, name: String) {
|
private fun RenderPlaceholder(widget: BricksWidget, name: String) {
|
||||||
androidx.compose.material3.Text(
|
Text(
|
||||||
text = "[${widget.widgettype}: $name - TODO]",
|
text = "[${widget.widgettype}: $name - TODO]",
|
||||||
modifier = Modifier.padding(8.dp)
|
modifier = Modifier.padding(8.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderRunningWidget(widget: BricksWidget) {
|
fun RenderRunningWidget(widget: BricksWidget) {
|
||||||
androidx.compose.material3.CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染整个 Widget 树
|
* 解析颜色字符串 (#RRGGBB 或 #RGB)
|
||||||
|
*/
|
||||||
|
private fun parseColor(colorStr: String): Color {
|
||||||
|
if (colorStr.isEmpty()) return Color.Unspecified
|
||||||
|
return try {
|
||||||
|
val hex = colorStr.trimStart('#')
|
||||||
|
val argb = when (hex.length) {
|
||||||
|
3 -> {
|
||||||
|
val r = hex[0].toString().repeat(2).toInt(16)
|
||||||
|
val g = hex[1].toString().repeat(2).toInt(16)
|
||||||
|
val b = hex[2].toString().repeat(2).toInt(16)
|
||||||
|
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
6 -> {
|
||||||
|
val rgb = hex.toInt(16)
|
||||||
|
(0xFF shl 24) or rgb
|
||||||
|
}
|
||||||
|
8 -> hex.toInt(16)
|
||||||
|
else -> return Color.Unspecified
|
||||||
|
}
|
||||||
|
Color(argb)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Color.Unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染整个 Widget 树 - 带 action dispatcher
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BricksApp(rootWidget: BricksWidget) {
|
fun BricksApp(
|
||||||
|
rootWidget: BricksWidget,
|
||||||
|
actionDispatcher: ActionDispatcher? = null
|
||||||
|
) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
RenderWidget(rootWidget)
|
RenderWidget(rootWidget, actionDispatcher)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import kotlinx.serialization.json.*
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class BricksWidget(
|
data class BricksWidget(
|
||||||
val widgettype: String,
|
val widgettype: String,
|
||||||
|
val id: String = "",
|
||||||
val options: Map<String, JsonElement> = emptyMap(),
|
val options: Map<String, JsonElement> = emptyMap(),
|
||||||
val subwidgets: List<BricksWidget> = emptyList(),
|
val subwidgets: List<BricksWidget> = emptyList(),
|
||||||
val binds: List<BricksBind> = emptyList()
|
val binds: List<BricksBind> = emptyList()
|
||||||
@ -22,7 +23,8 @@ data class BricksBind(
|
|||||||
val data: Map<String, JsonElement> = emptyMap(),
|
val data: Map<String, JsonElement> = emptyMap(),
|
||||||
val methodname: String? = null,
|
val methodname: String? = null,
|
||||||
val script: String? = null,
|
val script: String? = null,
|
||||||
val url: String? = null
|
val url: String? = null,
|
||||||
|
val options: Map<String, JsonElement> = emptyMap()
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
214
shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt
Normal file
214
shared/src/commonMain/kotlin/com/bricks/mp/sage/SageClient.kt
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
package com.bricks.mp.sage
|
||||||
|
|
||||||
|
import com.bricks.mp.core.BricksParser
|
||||||
|
import com.bricks.mp.core.BricksWidget
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.cookies.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sage 客户端 - 处理登录、Session 管理和 UI 加载
|
||||||
|
*
|
||||||
|
* Sage 使用 Cookie Session 认证:
|
||||||
|
* 1. POST /rbac/user/userpassword_login.dspy 登录获取 session cookie
|
||||||
|
* 2. 后续请求自动携带 cookie
|
||||||
|
* 3. GET /xxx.ui 获取 JSON 格式的 UI 描述
|
||||||
|
*/
|
||||||
|
class SageClient {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_BASE_URL = "https://ai.atvoe.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cookieStorage = AcceptAllCookiesStorage()
|
||||||
|
|
||||||
|
private val client = HttpClient(CIO) {
|
||||||
|
install(HttpCookies) {
|
||||||
|
storage = cookieStorage
|
||||||
|
}
|
||||||
|
expectSuccess = false
|
||||||
|
followRedirects = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseUrl: String = DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
// 登录状态
|
||||||
|
private val _isLoggedIn = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||||
|
val isLoggedIn = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
|
private val _loginError = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||||
|
val loginError = _loginError.asStateFlow()
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录 Sage 服务器
|
||||||
|
* POST /rbac/user/userpassword_login.dspy
|
||||||
|
*/
|
||||||
|
suspend fun login(username: String, password: String): Boolean = mutex.withLock {
|
||||||
|
_loginError.value = null
|
||||||
|
try {
|
||||||
|
val url = "$baseUrl/rbac/user/userpassword_login.dspy"
|
||||||
|
val formParameters = Parameters.build {
|
||||||
|
append("username", username)
|
||||||
|
append("passwd", password)
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = client.post(url) {
|
||||||
|
contentType(ContentType.Application.FormUrlEncoded)
|
||||||
|
setBody(formParameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.bodyAsText()
|
||||||
|
println("[Sage] Login response status: ${response.status}")
|
||||||
|
println("[Sage] Login response body: ${body.take(200)}")
|
||||||
|
|
||||||
|
// Sage 返回 UiMessage 格式: {"widgettype": "Message", "options": {...}}
|
||||||
|
// 或者返回错误: {"widgettype": "Error", "options": {...}}
|
||||||
|
val json = try {
|
||||||
|
Json.parseToJsonElement(body).jsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_loginError.value = "登录响应格式错误: ${e.message}"
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
|
||||||
|
val widgetType = json["widgettype"]?.jsonPrimitive?.content
|
||||||
|
|
||||||
|
if (widgetType == "Message" || widgetType == "UiMessage") {
|
||||||
|
// 检查是否有 session cookie
|
||||||
|
val cookies = cookieStorage.getCookies(URLBuilder(baseUrl).build())
|
||||||
|
if (cookies.isNotEmpty()) {
|
||||||
|
println("[Sage] Login successful, got ${cookies.size} cookies")
|
||||||
|
_isLoggedIn.value = true
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// 即使没有 cookie,如果服务器返回成功也算登录成功
|
||||||
|
// 有些部署可能使用 token 而非 cookie
|
||||||
|
println("[Sage] Login successful (no cookies)")
|
||||||
|
_isLoggedIn.value = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 错误消息
|
||||||
|
val options = json["options"]?.jsonObject ?: JsonObject(emptyMap())
|
||||||
|
val message = options["message"]?.jsonPrimitive?.content
|
||||||
|
?: options["text"]?.jsonPrimitive?.content
|
||||||
|
?: body.take(100)
|
||||||
|
_loginError.value = message
|
||||||
|
println("[Sage] Login failed: $message")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_loginError.value = "登录失败: ${e.message}"
|
||||||
|
println("[Sage] Login error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 .ui 文件(JSON 格式的 UI 描述)
|
||||||
|
*/
|
||||||
|
suspend fun fetchUi(path: String): Result<BricksWidget> = mutex.withLock {
|
||||||
|
try {
|
||||||
|
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
|
||||||
|
println("[Sage] Fetching UI: $url")
|
||||||
|
|
||||||
|
val response = client.get(url)
|
||||||
|
val body = response.bodyAsText()
|
||||||
|
|
||||||
|
if (!response.status.isSuccess()) {
|
||||||
|
return@withLock Result.failure(Exception("HTTP ${response.status.value}: ${body.take(200)}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val widget = BricksParser.parse(body)
|
||||||
|
Result.success(widget)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[Sage] Fetch UI error: ${e.message}")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 .dspy API 返回的 JSON
|
||||||
|
*/
|
||||||
|
suspend fun fetchApi(
|
||||||
|
path: String,
|
||||||
|
params: Map<String, String> = emptyMap(),
|
||||||
|
method: String = "GET",
|
||||||
|
formBody: Parameters? = null,
|
||||||
|
jsonBody: JsonObject? = null
|
||||||
|
): Result<JsonObject> = mutex.withLock {
|
||||||
|
try {
|
||||||
|
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
|
||||||
|
|
||||||
|
val response = when (method.uppercase()) {
|
||||||
|
"POST" -> client.post(url) {
|
||||||
|
url {
|
||||||
|
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
jsonBody != null -> {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(jsonBody)
|
||||||
|
}
|
||||||
|
formBody != null -> {
|
||||||
|
contentType(ContentType.Application.FormUrlEncoded)
|
||||||
|
setBody(formBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> client.get(url) {
|
||||||
|
url {
|
||||||
|
params.forEach { (k, v) -> parameters.append(k, v) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.bodyAsText()
|
||||||
|
val json = Json.parseToJsonElement(body).jsonObject
|
||||||
|
Result.success(json)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文本内容
|
||||||
|
*/
|
||||||
|
suspend fun fetchText(path: String): Result<String> = mutex.withLock {
|
||||||
|
try {
|
||||||
|
val url = if (path.startsWith("http")) path else "$baseUrl/${path.trimStart('/')}"
|
||||||
|
val response = client.get(url)
|
||||||
|
Result.success(response.bodyAsText())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
*/
|
||||||
|
suspend fun logout() = mutex.withLock {
|
||||||
|
try {
|
||||||
|
// 清除 cookies
|
||||||
|
cookieStorage.getCookies(URLBuilder(baseUrl).build())
|
||||||
|
_isLoggedIn.value = false
|
||||||
|
println("[Sage] Logged out")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[Sage] Logout error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭客户端
|
||||||
|
*/
|
||||||
|
fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -36,11 +36,10 @@ fun RenderInputWidget(widget: BricksWidget) {
|
|||||||
|
|
||||||
when (inputType) {
|
when (inputType) {
|
||||||
"password" -> {
|
"password" -> {
|
||||||
var visible by remember { mutableStateOf(false) }
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
visualTransformation = if (!visible) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -63,7 +62,6 @@ fun RenderInputWidget(widget: BricksWidget) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderTooltipWidget(widget: BricksWidget) {
|
fun RenderTooltipWidget(widget: BricksWidget) {
|
||||||
// Simplified tooltip - in real app would use TooltipBox
|
|
||||||
val text = WidgetOptions.getString(widget.options, "text", "")
|
val text = WidgetOptions.getString(widget.options, "text", "")
|
||||||
Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(text, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,72 +1,4 @@
|
|||||||
package com.bricks.mp.widgets
|
package com.bricks.mp.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
// Layout widgets are now rendered inline in BricksRenderer.kt
|
||||||
import androidx.compose.runtime.Composable
|
// This file is kept for backward compatibility
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.bricks.mp.core.BricksWidget
|
|
||||||
import com.bricks.mp.core.WidgetOptions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 布局组件
|
|
||||||
* HBox -> Row, VBox -> Column, Filler -> Spacer
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun RenderLayoutWidget(widget: BricksWidget) {
|
|
||||||
when (widget.widgettype) {
|
|
||||||
"HBox", "FHBox" -> {
|
|
||||||
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
|
||||||
"center" -> Alignment.CenterVertically
|
|
||||||
"end" -> Alignment.Bottom
|
|
||||||
else -> Alignment.Top
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp),
|
|
||||||
verticalAlignment = align,
|
|
||||||
horizontalArrangement = when (widget.widgettype) {
|
|
||||||
"FHBox" -> Arrangement.SpaceBetween
|
|
||||||
else -> Arrangement.Start
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"VBox", "FVBox" -> {
|
|
||||||
val align = when (WidgetOptions.getString(widget.options, "align", "start")) {
|
|
||||||
"center" -> Alignment.CenterHorizontally
|
|
||||||
"end" -> Alignment.End
|
|
||||||
else -> Alignment.Start
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = WidgetOptions.getInt(widget.options, "padding", 0).dp),
|
|
||||||
horizontalAlignment = align,
|
|
||||||
verticalArrangement = when (widget.widgettype) {
|
|
||||||
"FVBox" -> Arrangement.SpaceBetween
|
|
||||||
else -> Arrangement.Top
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
widget.subwidgets.forEach { child -> RenderWidget(child) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"Filler", "HFiller" -> {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
"VFiller" -> {
|
|
||||||
Spacer(modifier = Modifier.height(WidgetOptions.getInt(widget.options, "height", 16).dp))
|
|
||||||
}
|
|
||||||
"ResponsiveBox" -> {
|
|
||||||
BoxWithConstraints {
|
|
||||||
if (maxWidth > 600.dp) {
|
|
||||||
Row { widget.subwidgets.forEach { RenderWidget(it) } }
|
|
||||||
} else {
|
|
||||||
Column { widget.subwidgets.forEach { RenderWidget(it) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user