feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps

This commit is contained in:
2026-07-04 13:46:24 -06:00
parent 0f834078d5
commit 17b0ccf464
26 changed files with 2940 additions and 64 deletions

View File

@@ -55,6 +55,7 @@ kotlin {
dependsOn(wearMain)
dependencies {
implementation("androidx.activity:activity-compose:1.8.2")
implementation(project(":tailscale-wearos-android"))
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -59,17 +60,22 @@ class MainActivity : ComponentActivity() {
}
}
// Setup client
val client = MeatbagClient(
host = "10.0.0.5", // Your PC's local Wi-Fi IP
port = 8080,
attentionFeedback = feedback
)
// Connect client
client.connect(deviceType)
// SharedPreferences for connection details
val prefs = getSharedPreferences("meatbag_prefs", Context.MODE_PRIVATE)
setContent {
var host by remember { mutableStateOf(prefs.getString("host", "10.0.0.5") ?: "10.0.0.5") }
var portStr by remember { mutableStateOf(prefs.getString("port", "8080") ?: "8080") }
var authKey by remember { mutableStateOf(prefs.getString("auth_key", "") ?: "") }
var clientState by remember { mutableStateOf<MeatbagClient?>(null) }
val activePayload = clientState?.currentPayload?.collectAsState()?.value
val connectionState = clientState?.connectionState?.collectAsState()?.value ?: ConnectionState.DISCONNECTED
val scope = rememberCoroutineScope()
val context = this
var errorMessage by remember { mutableStateOf<String?>(null) }
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
@@ -82,10 +88,6 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val scope = rememberCoroutineScope()
val activePayload by client.currentPayload.collectAsState()
val connectionState by client.connectionState.collectAsState()
val scrollState = rememberScrollState()
Column(
@@ -93,7 +95,7 @@ class MainActivity : ComponentActivity() {
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(24.dp)
.padding(horizontal = 16.dp, vertical = 24.dp)
} else {
Modifier
.fillMaxSize()
@@ -108,12 +110,12 @@ class MainActivity : ComponentActivity() {
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
scope.launch {
client.respond(intent.toApprovalResponse())
clientState?.respond(intent.toApprovalResponse())
}
}
)
} else {
// Status Screen when idle
} else if (connectionState == ConnectionState.CONNECTED) {
// Status Screen when connected and idle
val idlePhrases = remember {
listOf(
"I got this, go do something illogical until I call for you.",
@@ -152,13 +154,13 @@ class MainActivity : ComponentActivity() {
Text(
text = displayText,
color = AccentTeal,
fontSize = if (isWatch) 14.sp else 18.sp,
fontSize = if (isWatch) 13.sp else 18.sp,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
Spacer(modifier = Modifier.height(if (isWatch) 10.dp else 24.dp))
// Action buttons to trigger response sayings
Column(
@@ -179,12 +181,12 @@ class MainActivity : ComponentActivity() {
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
.height(if (isWatch) 32.dp else 48.dp)
) {
Text(
text = "How's it going?",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontSize = if (isWatch) 10.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
@@ -202,12 +204,12 @@ class MainActivity : ComponentActivity() {
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
.height(if (isWatch) 32.dp else 48.dp)
) {
Text(
text = "Tell me what you're doing",
text = "Tell me what I'm doing",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontSize = if (isWatch) 10.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
@@ -215,27 +217,141 @@ class MainActivity : ComponentActivity() {
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
val statusText = when (connectionState) {
ConnectionState.CONNECTED -> "CONNECTED"
ConnectionState.CONNECTING -> "CONNECTING..."
ConnectionState.DISCONNECTED -> "DISCONNECTED"
}
val statusColor = when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> TextSecondary
ConnectionState.DISCONNECTED -> DangerRed
}
Text(
text = statusText,
color = statusColor,
fontSize = if (isWatch) 12.sp else 16.sp
text = "CONNECTED",
color = SuccessGreen,
fontSize = if (isWatch) 12.sp else 16.sp,
fontWeight = FontWeight.Bold
)
if (!isWatch) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
clientState?.disconnect()
TailscaleUserspace.stop()
clientState = null
},
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 32.dp else 48.dp)
.padding(horizontal = if (isWatch) 8.dp else 24.dp)
) {
Text(
text = "Disconnect",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
}
} else {
// Settings & Connection Panel
Text(
text = "MEATBAG",
color = AccentTeal,
fontSize = if (isWatch) 16.sp else 24.sp,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h5
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = if (connectionState == ConnectionState.CONNECTING) "CONNECTING..." else "DISCONNECTED",
color = if (connectionState == ConnectionState.CONNECTING) TextSecondary else DangerRed,
fontSize = if (isWatch) 11.sp else 14.sp
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host", fontSize = if (isWatch) 10.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port", fontSize = if (isWatch) 10.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = authKey,
onValueChange = { authKey = it },
label = { Text("Tailscale Auth Key (optional)", fontSize = if (isWatch) 9.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
errorMessage = null
// Save preferences
prefs.edit().apply {
putString("host", host)
putString("port", portStr)
putString("auth_key", authKey)
apply()
}
scope.launch(Dispatchers.Default) {
var socksPort: Int? = null
if (authKey.isNotBlank()) {
val tsResult = TailscaleUserspace.start(context, authKey.trim())
if (tsResult.isSuccess) {
socksPort = tsResult.getOrNull()
} else {
errorMessage = tsResult.exceptionOrNull()?.message
return@launch
}
}
val newClient = MeatbagClient(
host = host.trim(),
port = portStr.trim().toIntOrNull() ?: 8080,
attentionFeedback = feedback,
socksPort = socksPort
)
clientState = newClient
newClient.connect(deviceType)
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp),
enabled = connectionState == ConnectionState.DISCONNECTED
) {
Text(
text = "Connect",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
}
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Host: 10.0.0.5:8080",
color = TextSecondary,
fontSize = 14.sp
text = errorMessage!!,
color = DangerRed,
fontSize = 10.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}

View File

@@ -0,0 +1,34 @@
package meatbag.client
import android.content.Context
import com.tailscale.wearos.TailscaleConnection
object TailscaleUserspace {
private var runningPort: Int? = null
fun isRunning(): Boolean = runningPort != null
fun getPort(): Int? = runningPort
/**
* Starts the embedded Tailscale proxy in userspace mode.
* Returns the local SOCKS5 proxy port if successful.
*/
fun start(context: Context, authKey: String): Result<Int> {
if (runningPort != null) return Result.success(runningPort!!)
val result = TailscaleConnection.start(context, authKey.trim())
if (result.isSuccess) {
runningPort = result.getOrNull()
}
return result
}
/**
* Stops the running Tailscale proxy
*/
fun stop() {
if (runningPort == null) return
TailscaleConnection.stop()
runningPort = null
}
}

View File

@@ -34,7 +34,8 @@ enum class LogType {
class MeatbagClient(
private val host: String = "localhost",
private val port: Int = 8080,
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback,
private val socksPort: Int? = null
) {
private val jsonConfig = Json {
ignoreUnknownKeys = true
@@ -42,6 +43,11 @@ class MeatbagClient(
}
private val client = HttpClient(CIO) {
engine {
if (socksPort != null) {
proxy = io.ktor.client.engine.ProxyBuilder.socks(host = "127.0.0.1", port = socksPort)
}
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(jsonConfig)
}
@@ -167,6 +173,27 @@ class MeatbagClient(
}
}
suspend fun syncProfile(profile: meatbag.common.UserProfile): Boolean {
return try {
log("Syncing profile ${profile.id} to http://$host:$port/api/profiles ...")
val httpResponse = client.post("http://$host:$port/api/profiles") {
contentType(ContentType.Application.Json)
setBody(profile)
}
if (httpResponse.status.isSuccess()) {
log("Profile '${profile.name}' synced successfully!", LogType.SUCCESS)
true
} else {
val body = httpResponse.bodyAsText()
log("Server rejected profile sync: ${httpResponse.status} - $body", LogType.ERROR)
false
}
} catch (e: Exception) {
log("Error syncing profile: ${e.message}", LogType.ERROR)
false
}
}
private fun log(message: String, type: LogType = LogType.INFO) {
println("[MeatbagClient] $type: $message")
_events.tryEmit(ClientEvent.LogMessage(message, type))

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -93,6 +95,7 @@ fun main() = application {
@Composable
fun SimulatorApp() {
var activeTab by remember { mutableStateOf(0) }
var client by remember { mutableStateOf(MeatbagClient(attentionFeedback = DesktopAttentionFeedback())) }
val scope = rememberCoroutineScope()
@@ -152,13 +155,33 @@ fun SimulatorApp() {
}
}
Row(
modifier = Modifier
.fillMaxSize()
.background(SpaceDark)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(modifier = Modifier.fillMaxSize().background(SpaceDark)) {
TabRow(
selectedTabIndex = activeTab,
backgroundColor = SurfaceDark,
contentColor = AccentTeal,
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
Tab(
selected = activeTab == 0,
onClick = { activeTab = 0 },
text = { Text("AGENT SIMULATOR", fontWeight = FontWeight.Bold, letterSpacing = 1.sp) }
)
Tab(
selected = activeTab == 1,
onClick = { activeTab = 1 },
text = { Text("DRAG-AND-DROP UI BUILDER", fontWeight = FontWeight.Bold, letterSpacing = 1.sp) }
)
}
if (activeTab == 0) {
Row(
modifier = Modifier
.fillMaxSize()
.background(SpaceDark)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// COLUMN 1: CONTROL PANEL & MOCKS
Column(
modifier = Modifier
@@ -654,5 +677,531 @@ fun SimulatorApp() {
}
}
}
} } else {
UIBuilderScreen(client)
}
}
}
@Composable
fun UIBuilderScreen(client: MeatbagClient) {
val scope = rememberCoroutineScope()
var profileId by remember { mutableStateOf("my_custom_profile") }
var profileName by remember { mutableStateOf("My Custom Profile") }
var profileDescription by remember { mutableStateOf("This profile was generated using the visual UI Builder.") }
// Active Primitive Type: 0 = Message, 1 = ApprovalRequest, 2 = ActionList, 3 = TextInput
var activeComponentType by remember { mutableStateOf(0) }
// Component Properties
// Message
var msgId by remember { mutableStateOf("msg_1") }
var msgTitle by remember { mutableStateOf("Alert Notification") }
var msgBody by remember { mutableStateOf("A critical update is available for deployment.") }
var msgTone by remember { mutableStateOf(MessageTone.INFO) }
var msgPrimaryLabel by remember { mutableStateOf("Acknowledge") }
// ApprovalRequest
var appReqId by remember { mutableStateOf("req_1") }
var appReqPrompt by remember { mutableStateOf("Do you want to deploy to staging?") }
var appReqTarget by remember { mutableStateOf("deployment-agent") }
var appReqTitle by remember { mutableStateOf("Staging Deployment Confirmation") }
var appReqBody by remember { mutableStateOf("Proceeding will trigger git merge and deploy.") }
var appReqApproveLabel by remember { mutableStateOf("Approve") }
var appReqRejectLabel by remember { mutableStateOf("Reject") }
// ActionList
var actListId by remember { mutableStateOf("list_1") }
var actListTitle by remember { mutableStateOf("Select Action") }
var actListSubtitle by remember { mutableStateOf("Select an action below to proceed:") }
val actListItems = remember {
mutableStateListOf(
ActionItem("action_a", "Run Database Migration", ActionIntent.CUSTOM),
ActionItem("action_b", "Skip Database Migration", ActionIntent.CUSTOM)
)
}
// TextInput
var txtInputId by remember { mutableStateOf("input_1") }
var txtInputPrompt by remember { mutableStateOf("Enter git commit message:") }
var txtInputPlaceholder by remember { mutableStateOf("e.g. fix: solve database deadlock") }
var txtInputTitle by remember { mutableStateOf("Git Commit Info") }
var txtInputSubmitLabel by remember { mutableStateOf("Submit") }
// Computed Active UiPrimitive
val activePrimitive: UiPrimitive = when (activeComponentType) {
0 -> Message(
id = msgId,
title = msgTitle,
body = msgBody,
tone = msgTone,
primaryAction = if (msgPrimaryLabel.isNotBlank()) ActionItem(msgPrimaryLabel.lowercase().replace(" ", "_"), msgPrimaryLabel, ActionIntent.ACKNOWLEDGE) else null
)
1 -> ApprovalRequest(
id = appReqId,
prompt = appReqPrompt,
target = appReqTarget,
title = appReqTitle,
body = if (appReqBody.isNotBlank()) appReqBody else null,
approveAction = ActionItem("approve", appReqApproveLabel, ActionIntent.APPROVE),
rejectAction = ActionItem("reject", appReqRejectLabel, ActionIntent.REJECT, ActionStyle.DESTRUCTIVE)
)
2 -> ActionList(
id = actListId,
title = actListTitle,
subtitle = if (actListSubtitle.isNotBlank()) actListSubtitle else null,
actions = actListItems.toList()
)
3 -> TextInput(
id = txtInputId,
prompt = txtInputPrompt,
placeholder = txtInputPlaceholder,
title = txtInputTitle,
submitAction = ActionItem("submit", txtInputSubmitLabel, ActionIntent.SUBMIT)
)
else -> error("Invalid component type")
}
// Sync feedback state
var syncMessage by remember { mutableStateOf<String?>(null) }
var syncSuccess by remember { mutableStateOf(true) }
Row(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// COLUMN 1: TOOLBOX (Left)
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier.width(300.dp).fillMaxHeight()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"UI Builder Toolbox",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
"Select a primitive type to design and configure:",
color = TextSecondary,
fontSize = 12.sp
)
val componentsList = listOf("Message Card", "Approval Request", "Action List", "Text Input")
componentsList.forEachIndexed { index, name ->
val isSelected = activeComponentType == index
Button(
onClick = { activeComponentType = index },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isSelected) AccentTeal else SpaceDark
),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(48.dp).border(1.dp, if (isSelected) AccentTeal else Color.White.copy(alpha = 0.05f), RoundedCornerShape(10.dp))
) {
Text(
name,
color = if (isSelected) DeepBg else Color.White,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Profile Configuration Section
Text(
"Profile Settings",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
OutlinedTextField(
value = profileId,
onValueChange = { profileId = it },
label = { Text("Profile ID", color = TextSecondary, fontSize = 10.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name", color = TextSecondary, fontSize = 10.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = profileDescription,
onValueChange = { profileDescription = it },
label = { Text("Description", color = TextSecondary, fontSize = 10.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
}
// COLUMN 2: LIVE CANVAS / PREVIEW (Center)
Column(
modifier = Modifier.weight(1.5f).fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Live Renderer Preview Frame
Text(
"LIVE RENDERING CANVAS (SMARTWATCH WRAPPER)",
color = TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
// Watch preview frame
Box(
modifier = Modifier
.size(360.dp)
.clip(CircleShape)
.background(Color.Black)
.border(6.dp, Color(0xFF3A3E52), CircleShape)
.border(10.dp, Brush.sweepGradient(listOf(AccentTeal, AccentPurple, AccentTeal)), CircleShape)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
MeatbagComponentHost(
state = ComponentRenderState(WebSocketPayload(AttentionTier.SILENT_HAPTIC, activePrimitive)),
onIntent = { /* Preview mode, noop actions */ }
)
}
}
// Action / Sync Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = {
val profile = UserProfile(
id = profileId.trim(),
name = profileName.trim(),
description = if (profileDescription.isNotBlank()) profileDescription.trim() else null,
layout = activePrimitive
)
scope.launch {
val success = client.syncProfile(profile)
syncSuccess = success
syncMessage = if (success) {
"Profile '${profile.name}' synced successfully to backend!"
} else {
"Error: Failed to sync profile to server."
}
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.weight(1f).height(48.dp)
) {
Text("Sync to WhetForge Cloud", color = Color.White, fontWeight = FontWeight.Bold)
}
var showJsonDialog by remember { mutableStateOf(false) }
Button(
onClick = { showJsonDialog = true },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.weight(1f).height(48.dp)
) {
Text("Export Schema JSON", color = Color.White, fontWeight = FontWeight.Bold)
}
if (showJsonDialog) {
val profile = UserProfile(
id = profileId.trim(),
name = profileName.trim(),
description = if (profileDescription.isNotBlank()) profileDescription.trim() else null,
layout = activePrimitive
)
AlertDialog(
onDismissRequest = { showJsonDialog = false },
title = { Text("Serialized JSON Profile", color = Color.White, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = profile.toJson(),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth().height(260.dp),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White)
)
},
confirmButton = {
Button(onClick = { showJsonDialog = false }) {
Text("Close")
}
},
backgroundColor = SurfaceDark,
contentColor = Color.White
)
}
}
// Sync status alert banner
if (syncMessage != null) {
Card(
backgroundColor = if (syncSuccess) SuccessGreen.copy(alpha = 0.2f) else DangerRed.copy(alpha = 0.2f),
border = BorderStroke(1.dp, if (syncSuccess) SuccessGreen else DangerRed),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(syncMessage!!, color = Color.White, fontSize = 13.sp)
IconButton(
onClick = { syncMessage = null },
modifier = Modifier.size(24.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
}
// COLUMN 3: PROPERTIES INSPECTOR (Right)
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier.width(420.dp).fillMaxHeight()
) {
Column(
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Component Properties",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Divider(color = Color.White.copy(alpha = 0.1f))
when (activeComponentType) {
0 -> { // Message
OutlinedTextField(
value = msgId,
onValueChange = { msgId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = msgTitle,
onValueChange = { msgTitle = it },
label = { Text("Title", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = msgBody,
onValueChange = { msgBody = it },
label = { Text("Body Text", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth(),
maxLines = 4
)
// Tone selection
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Message Tone", color = TextSecondary, fontSize = 12.sp)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
MessageTone.values().forEach { tone ->
val isSelected = msgTone == tone
Button(
onClick = { msgTone = tone },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isSelected) AccentPurple else SpaceDark
),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.weight(1f).height(32.dp),
contentPadding = PaddingValues(2.dp)
) {
Text(tone.name, color = Color.White, fontSize = 8.sp, fontWeight = FontWeight.Bold)
}
}
}
}
OutlinedTextField(
value = msgPrimaryLabel,
onValueChange = { msgPrimaryLabel = it },
label = { Text("Primary Action Label (optional)", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
1 -> { // ApprovalRequest
OutlinedTextField(
value = appReqId,
onValueChange = { appReqId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqTitle,
onValueChange = { appReqTitle = it },
label = { Text("Title Header", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqPrompt,
onValueChange = { appReqPrompt = it },
label = { Text("Urgent Prompt Message", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqBody,
onValueChange = { appReqBody = it },
label = { Text("Body Text Details (optional)", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqTarget,
onValueChange = { appReqTarget = it },
label = { Text("Target Agent / Namespace", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqApproveLabel,
onValueChange = { appReqApproveLabel = it },
label = { Text("Approve Button Label", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqRejectLabel,
onValueChange = { appReqRejectLabel = it },
label = { Text("Reject Button Label", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
2 -> { // ActionList
OutlinedTextField(
value = actListId,
onValueChange = { actListId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = actListTitle,
onValueChange = { actListTitle = it },
label = { Text("Title", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = actListSubtitle,
onValueChange = { actListSubtitle = it },
label = { Text("Subtitle (optional)", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Text("Action Options", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.sp)
actListItems.forEachIndexed { i, item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = item.label,
onValueChange = { newLabel ->
actListItems[i] = item.copy(label = newLabel)
},
label = { Text("Option ${i+1}", color = TextSecondary, fontSize = 9.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { actListItems.removeAt(i) },
modifier = Modifier.size(36.dp).padding(top = 10.dp)
) {
Icon(Icons.Default.Delete, contentDescription = "Delete Option", tint = DangerRed)
}
}
}
Button(
onClick = {
val nextIndex = actListItems.size + 1
actListItems.add(ActionItem("action_$nextIndex", "New Action Option $nextIndex", ActionIntent.CUSTOM))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().height(36.dp).border(1.dp, AccentTeal.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
) {
Text("Add Action Option", color = AccentTeal, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
3 -> { // TextInput
OutlinedTextField(
value = txtInputId,
onValueChange = { txtInputId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputTitle,
onValueChange = { txtInputTitle = it },
label = { Text("Title Header", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputPrompt,
onValueChange = { txtInputPrompt = it },
label = { Text("Text Prompt / Question", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputPlaceholder,
onValueChange = { txtInputPlaceholder = it },
label = { Text("Placeholder Text Hint", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputSubmitLabel,
onValueChange = { txtInputSubmitLabel = it },
label = { Text("Submit Button Label", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}