feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps
This commit is contained in:
@@ -55,6 +55,7 @@ kotlin {
|
||||
dependsOn(wearMain)
|
||||
dependencies {
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation(project(":tailscale-wearos-android"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user