Initialize Meatbag SDUI platform

This commit is contained in:
2026-06-30 15:12:58 -06:00
commit 8b4cb8eace
28 changed files with 3058 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
package meatbag.client
import meatbag.common.AttentionTier
class AndroidAttentionFeedback(
private val notify: suspend (AndroidAttentionSignal) -> Unit
) : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) {
notify(tier.toAndroidAttentionSignal())
}
}
enum class AndroidAttentionSignal {
SilentHaptic,
BackgroundNotification,
OverrideScreen
}
fun AttentionTier.toAndroidAttentionSignal(): AndroidAttentionSignal {
return when (this) {
AttentionTier.SILENT_HAPTIC -> AndroidAttentionSignal.SilentHaptic
AttentionTier.BACKGROUND_NOTIFICATION -> AndroidAttentionSignal.BackgroundNotification
AttentionTier.OVERRIDE_SCREEN -> AndroidAttentionSignal.OverrideScreen
}
}

View File

@@ -0,0 +1,11 @@
package meatbag.client
import meatbag.common.AttentionTier
interface AttentionFeedback {
suspend fun onAttentionTier(tier: AttentionTier)
}
object NoOpAttentionFeedback : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
}

View File

@@ -0,0 +1,476 @@
package meatbag.client
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
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 meatbag.common.ActionList
import meatbag.common.ApprovalResponse
import meatbag.common.ApprovalRequest
import meatbag.common.Message
import meatbag.common.TextInput
import meatbag.common.UiPrimitive
import meatbag.common.WebSocketPayload
val DeepBg = Color(0xFF0F111A)
val CardBg = Color(0xFF1E2235)
val AccentTeal = Color(0xFF00F2FE)
val AccentPurple = Color(0xFF9B51E0)
val DangerRed = Color(0xFFFF4D4D)
val SuccessGreen = Color(0xFF27AE60)
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF8E92B2)
data class ComponentRenderState(
val payload: WebSocketPayload
)
sealed interface ComponentIntent {
val requestId: String
data class ApprovalDecision(
override val requestId: String,
val approved: Boolean
) : ComponentIntent
data class ActionSelected(
override val requestId: String,
val actionId: String
) : ComponentIntent
data class TextSubmitted(
override val requestId: String,
val text: String
) : ComponentIntent
data class Acknowledged(
override val requestId: String,
val actionId: String? = null
) : ComponentIntent
}
fun ComponentIntent.toApprovalResponse(): ApprovalResponse {
return when (this) {
is ComponentIntent.Acknowledged -> ApprovalResponse(
requestId = requestId,
approved = true,
actionId = actionId
)
is ComponentIntent.ActionSelected -> ApprovalResponse(
requestId = requestId,
approved = true,
actionId = actionId
)
is ComponentIntent.ApprovalDecision -> ApprovalResponse(
requestId = requestId,
approved = approved
)
is ComponentIntent.TextSubmitted -> ApprovalResponse(
requestId = requestId,
approved = true,
text = text
)
}
}
@Composable
fun MeatbagComponentHost(
state: ComponentRenderState,
onIntent: (ComponentIntent) -> Unit,
modifier: Modifier = Modifier
) {
ComponentRegistry.Render(
primitive = state.payload.uiPrimitive,
onIntent = onIntent,
modifier = modifier
)
}
object ComponentRegistry {
private val renderers = listOf(
ApprovalRequestRenderer,
ActionListRenderer,
TextInputRenderer,
MessageRenderer
)
@Composable
fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(24.dp))
.background(CardBg)
.border(
1.dp,
Brush.linearGradient(
listOf(
AccentTeal.copy(alpha = 0.3f),
AccentPurple.copy(alpha = 0.3f)
)
),
RoundedCornerShape(24.dp)
)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
val renderer = renderers.firstOrNull { it.accepts(primitive) }
?: error("No renderer registered for primitive id=${primitive.id}")
renderer.Render(primitive, onIntent)
}
}
}
private interface PrimitiveRenderer {
fun accepts(primitive: UiPrimitive): Boolean
@Composable
fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
)
}
private object ApprovalRequestRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is ApprovalRequest
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
ApprovalRequestComponent(
request = primitive as ApprovalRequest,
onDecision = { approved ->
onIntent(ComponentIntent.ApprovalDecision(primitive.id, approved))
}
)
}
}
private object ActionListRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is ActionList
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
ActionListComponent(
list = primitive as ActionList,
onActionSelected = { actionId ->
onIntent(ComponentIntent.ActionSelected(primitive.id, actionId))
}
)
}
}
private object TextInputRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is TextInput
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
TextInputComponent(
input = primitive as TextInput,
onSubmit = { text ->
onIntent(ComponentIntent.TextSubmitted(primitive.id, text))
}
)
}
}
private object MessageRenderer : PrimitiveRenderer {
override fun accepts(primitive: UiPrimitive): Boolean = primitive is Message
@Composable
override fun Render(
primitive: UiPrimitive,
onIntent: (ComponentIntent) -> Unit
) {
MessageComponent(
message = primitive as Message,
onAcknowledge = { actionId ->
onIntent(ComponentIntent.Acknowledged(primitive.id, actionId))
}
)
}
}
@Composable
fun ApprovalRequestComponent(
request: ApprovalRequest,
onDecision: (approved: Boolean) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "TARGET: ${request.target.uppercase()}",
color = AccentTeal,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.background(AccentTeal.copy(alpha = 0.1f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
)
Text(
text = "Approval Request",
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(DeepBg, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(16.dp)
) {
Text(
text = request.prompt,
color = Color.White,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = { onDecision(false) },
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(48.dp)
) {
Text("Reject", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
Button(
onClick = { onDecision(true) },
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(48.dp)
) {
Text("Approve", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
}
}
}
@Composable
fun ActionListComponent(
list: ActionList,
onActionSelected: (actionId: String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = list.title,
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
list.actions.forEach { actionItem ->
Button(
onClick = { onActionSelected(actionItem.id) },
colors = ButtonDefaults.buttonColors(backgroundColor = DeepBg),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.border(
1.dp,
Brush.linearGradient(
listOf(
AccentTeal.copy(alpha = 0.5f),
AccentPurple.copy(alpha = 0.5f)
)
),
RoundedCornerShape(12.dp)
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = actionItem.label,
color = TextPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
Text(
text = ">",
color = AccentTeal,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
@Composable
fun TextInputComponent(
input: TextInput,
onSubmit: (text: String) -> Unit
) {
var textVal by remember(input.id) { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Input Required",
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Text(
text = input.prompt,
color = TextSecondary,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
OutlinedTextField(
value = textVal,
onValueChange = { textVal = it },
placeholder = { Text(input.placeholder, color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = TextPrimary,
focusedBorderColor = AccentTeal,
unfocusedBorderColor = TextSecondary.copy(alpha = 0.5f),
cursorColor = AccentTeal
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = { onSubmit(textVal) },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = textVal.isNotBlank()
) {
Text("Submit", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
}
}
@Composable
fun MessageComponent(
message: Message,
onAcknowledge: (actionId: String?) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = message.title,
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(DeepBg, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(16.dp)
) {
Text(
text = message.body,
color = TextSecondary,
fontSize = 14.sp,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
Button(
onClick = { onAcknowledge(message.primaryAction?.id) },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentTeal),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
enabled = message.primaryAction?.enabled ?: true
) {
Text(
text = message.primaryAction?.label ?: "Acknowledge",
color = DeepBg,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
}
}
}

View File

@@ -0,0 +1,169 @@
package meatbag.client
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.websocket.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.websocket.readText
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import meatbag.common.AttentionTier
import meatbag.common.WebSocketPayload
import meatbag.common.ApprovalResponse
import meatbag.common.toWebSocketPayload
import meatbag.common.toJson
sealed interface ClientEvent {
data class HapticTrigger(val tier: AttentionTier, val message: String) : ClientEvent
data class LogMessage(val text: String, val type: LogType = LogType.INFO) : ClientEvent
}
enum class LogType {
INFO, SUCCESS, WARNING, ERROR, HAPTIC
}
class MeatbagClient(
private val host: String = "localhost",
private val port: Int = 8080,
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback
) {
private val jsonConfig = Json {
ignoreUnknownKeys = true
classDiscriminator = "type"
}
private val client = HttpClient(CIO) {
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(jsonConfig)
}
install(ContentNegotiation) {
json(jsonConfig)
}
}
private val _currentPayload = MutableStateFlow<WebSocketPayload?>(null)
val currentPayload = _currentPayload.asStateFlow()
private val _events = MutableSharedFlow<ClientEvent>(extraBufferCapacity = 100)
val events = _events.asSharedFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected = _isConnected.asStateFlow()
private var wsSession: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null
fun connect(deviceType: String) {
connectionJob?.cancel()
connectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isActive) {
try {
log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...")
client.webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/ws/connect?deviceType=$deviceType"
) {
wsSession = this
_isConnected.value = true
log("Connected successfully as $deviceType!", LogType.SUCCESS)
for (frame in incoming) {
when (frame) {
is io.ktor.websocket.Frame.Text -> {
val text = frame.readText()
log("Received WebSocket frame: $text")
try {
val payload = text.toWebSocketPayload()
log("Parsed payload: primitive=${payload.uiPrimitive.id}, tier=${payload.attentionTier}", LogType.SUCCESS)
// Trigger haptics, alerts and audio simulations
handleAttentionTier(payload.attentionTier)
// Update local state
_currentPayload.value = payload
} catch (e: Exception) {
log("Failed to parse WebSocketPayload: ${e.message}", LogType.ERROR)
}
}
else -> {}
}
}
}
} catch (e: Exception) {
_isConnected.value = false
log("WebSocket error/disconnect: ${e.message}. Reconnecting in 3s...", LogType.WARNING)
delay(3000)
} finally {
_isConnected.value = false
wsSession = null
}
}
}
}
fun disconnect() {
connectionJob?.cancel()
wsSession = null
_isConnected.value = false
log("Disconnected by user.")
}
private suspend fun handleAttentionTier(tier: AttentionTier) {
attentionFeedback.onAttentionTier(tier)
val message = when (tier) {
AttentionTier.SILENT_HAPTIC -> {
"[VIBRATION] Double short haptic pulses triggered silently on Wear OS."
}
AttentionTier.BACKGROUND_NOTIFICATION -> {
"[SOUND/BANNER] Play standard notification chime + display overlay banner."
}
AttentionTier.OVERRIDE_SCREEN -> {
"[OVERRIDE] Play high-priority alert loop + lock display and takeover interface."
}
}
_events.emit(ClientEvent.HapticTrigger(tier, message))
log("Attention Action: $message", LogType.HAPTIC)
}
suspend fun respond(response: ApprovalResponse): Boolean {
return try {
log("Sending response to POST http://$host:$port/api/respond : ${response.toJson()}")
val httpResponse = client.post("http://$host:$port/api/respond") {
contentType(ContentType.Application.Json)
setBody(response)
}
if (httpResponse.status.isSuccess()) {
log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS)
if (_currentPayload.value?.uiPrimitive?.id == response.requestId) {
_currentPayload.value = null
}
true
} else {
log("Server rejected response: ${httpResponse.status} - ${httpResponse.bodyAsText()}", LogType.ERROR)
false
}
} catch (e: Exception) {
log("Error sending response: ${e.message}", LogType.ERROR)
false
}
}
private fun log(message: String, type: LogType = LogType.INFO) {
_events.tryEmit(ClientEvent.LogMessage(message, type))
}
fun injectMockEvent(event: ClientEvent) {
_events.tryEmit(event)
}
}

View File

@@ -0,0 +1,7 @@
package meatbag.client
import meatbag.common.AttentionTier
class DesktopAttentionFeedback : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
}

View File

@@ -0,0 +1,658 @@
package meatbag.client
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import meatbag.common.*
import java.time.LocalTime
import java.time.format.DateTimeFormatter
// Theme Palette (Dark Cyberpunk / Glassmorphism)
val SpaceDark = Color(0xFF090A0F)
val SurfaceDark = Color(0xFF131520)
val ConsoleDark = Color(0xFF07080D)
val AccentCyan = Color(0xFF00E5FF)
val AccentPurpleGlow = Color(0xFFBD00FF)
val ActiveGreen = Color(0xFF00FF66)
val AlertOrange = Color(0xFFFF9900)
data class LogEntry(
val timestamp: String,
val text: String,
val type: LogType
)
private fun handleComponentIntent(
intent: ComponentIntent,
localMockPayload: WebSocketPayload?,
client: MeatbagClient,
onMockResolved: () -> Unit,
scope: CoroutineScope
) {
val response = intent.toApprovalResponse()
scope.launch {
if (localMockPayload?.uiPrimitive?.id == response.requestId) {
client.injectMockEvent(
ClientEvent.LogMessage(
"Mock response triggered: ${response.toJson()}",
LogType.SUCCESS
)
)
onMockResolved()
} else {
client.respond(response)
}
}
}
fun main() = application {
val windowState = rememberWindowState(width = 1280.dp, height = 800.dp)
Window(
onCloseRequest = ::exitApplication,
state = windowState,
title = "Meatbag SDUI Client Simulator"
) {
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
secondary = AccentPurple,
background = SpaceDark,
surface = SurfaceDark
)
) {
SimulatorApp()
}
}
}
@Composable
fun SimulatorApp() {
var client by remember { mutableStateOf(MeatbagClient(attentionFeedback = DesktopAttentionFeedback())) }
val scope = rememberCoroutineScope()
val networkPayload by client.currentPayload.collectAsState()
var localMockPayload by remember { mutableStateOf<WebSocketPayload?>(null) }
val activePayload = localMockPayload ?: networkPayload
val isConnected by client.isConnected.collectAsState()
var deviceType by remember { mutableStateOf("WEAR_OS_WATCH") }
var hostInput by remember { mutableStateOf("localhost") }
var portInput by remember { mutableStateOf("8080") }
val logs = remember { mutableStateListOf<LogEntry>() }
val lazyListState = rememberLazyListState()
var bannerMessage by remember { mutableStateOf<String?>(null) }
var bannerTier by remember { mutableStateOf<AttentionTier?>(null) }
var showBanner by remember { mutableStateOf(false) }
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
// Listen to client events
LaunchedEffect(client) {
client.events.collectLatest { event ->
val timestamp = LocalTime.now().format(timeFormatter)
when (event) {
is ClientEvent.LogMessage -> {
logs.add(LogEntry(timestamp, event.text, event.type))
// Auto scroll to bottom
if (logs.size > 0) {
lazyListState.animateScrollToItem(logs.size - 1)
}
}
is ClientEvent.HapticTrigger -> {
// Show visual banner in simulator
bannerTier = event.tier
bannerMessage = event.message
showBanner = true
// Auto hide banner unless it is OVERRIDE_SCREEN
if (event.tier != AttentionTier.OVERRIDE_SCREEN) {
scope.launch {
kotlinx.coroutines.delay(4000)
showBanner = false
}
}
}
}
}
}
// Reset banner when payload is resolved/dismissed
LaunchedEffect(activePayload) {
if (activePayload == null) {
showBanner = false
bannerMessage = null
bannerTier = null
}
}
Row(
modifier = Modifier
.fillMaxSize()
.background(SpaceDark)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// COLUMN 1: CONTROL PANEL & MOCKS
Column(
modifier = Modifier
.width(320.dp)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Connection Card
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Connection Settings",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
OutlinedTextField(
value = hostInput,
onValueChange = { hostInput = it },
label = { Text("Host", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = portInput,
onValueChange = { portInput = it },
label = { Text("Port", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
// Device Selector
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Simulated Device Profile", color = TextSecondary, fontSize = 12.sp)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
val profiles = listOf("WEAR_OS_WATCH", "PHONE", "DESKTOP")
profiles.forEach { profile ->
val isSelected = deviceType == profile
Button(
onClick = {
deviceType = profile
if (isConnected) {
client.disconnect()
client.connect(profile)
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isSelected) AccentTeal else SpaceDark
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.weight(1f).height(36.dp),
contentPadding = PaddingValues(2.dp)
) {
Text(
profile.replace("WEAR_OS_", ""),
color = if (isSelected) DeepBg else Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
// Connection Action
Button(
onClick = {
if (isConnected) {
client.disconnect()
} else {
client.disconnect()
client = MeatbagClient(
host = hostInput,
port = portInput.toIntOrNull() ?: 8080,
attentionFeedback = DesktopAttentionFeedback()
)
client.connect(deviceType)
}
},
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isConnected) DangerRed else SuccessGreen
),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (isConnected) ActiveGreen else Color.Gray)
)
Text(
if (isConnected) "Disconnect" else "Connect WebSocket",
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
}
// Mock Data Injector Card
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier.weight(1f)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Local Ping-Pong Mocking",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
"Trigger mock payloads to test layout and events without Ktor server running.",
color = TextSecondary,
fontSize = 12.sp
)
Button(
onClick = {
localMockPayload = WebSocketPayload(
attentionTier = AttentionTier.SILENT_HAPTIC,
uiPrimitive = ApprovalRequest(
id = "mock_approval_1",
prompt = "Allow agent to execute 'git push --force origin main'?",
target = "deploy-bot"
)
)
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ApprovalRequest with SILENT_HAPTIC", LogType.INFO))
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.SILENT_HAPTIC, "[VIBRATION] Double short haptic pulses triggered silently on Wear OS."))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
) {
Text("Mock Approval (Silent Haptic)", color = AccentTeal, fontSize = 12.sp)
}
Button(
onClick = {
localMockPayload = WebSocketPayload(
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
uiPrimitive = ActionList(
id = "mock_action_2",
title = "Select Environment to Deploy",
actions = listOf(
ActionItem("staging", "Staging Environment (UAT)"),
ActionItem("prod", "Production (US-EAST-1)"),
ActionItem("sandbox", "Sandbox Playpen")
)
)
)
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ActionList with BACKGROUND_NOTIFICATION", LogType.INFO))
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.BACKGROUND_NOTIFICATION, "[SOUND/BANNER] Play standard notification chime + display overlay banner."))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
) {
Text("Mock ActionList (Banner Alert)", color = AccentTeal, fontSize = 12.sp)
}
Button(
onClick = {
localMockPayload = WebSocketPayload(
attentionTier = AttentionTier.OVERRIDE_SCREEN,
uiPrimitive = TextInput(
id = "mock_input_3",
prompt = "CRITICAL: Database Migration has failed. Type 'CONTINUE' to bypass or 'ABORT' to rollback.",
placeholder = "Type command here..."
)
)
client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: TextInput with OVERRIDE_SCREEN", LogType.INFO))
client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.OVERRIDE_SCREEN, "[OVERRIDE] Play high-priority alert loop + lock display and takeover interface."))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
) {
Text("Mock TextInput (Override)", color = AccentTeal, fontSize = 12.sp)
}
Spacer(modifier = Modifier.weight(1f))
if (activePayload != null) {
Button(
onClick = {
localMockPayload = null
client.injectMockEvent(ClientEvent.LogMessage("Mock state cleared.", LogType.INFO))
},
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed.copy(alpha = 0.2f)),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(40.dp).border(1.dp, DangerRed, RoundedCornerShape(10.dp))
) {
Text("Clear Active UI", color = DangerRed, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
// COLUMN 2: SIMULATOR CANVAS (CENTER)
Column(
modifier = Modifier
.weight(1.5f)
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Visual Attention Banner (Overlay effect)
if (showBanner && bannerMessage != null) {
val bannerColor = when (bannerTier) {
AttentionTier.SILENT_HAPTIC -> AccentTeal
AttentionTier.BACKGROUND_NOTIFICATION -> AlertOrange
AttentionTier.OVERRIDE_SCREEN -> DangerRed
else -> AccentTeal
}
Card(
backgroundColor = bannerColor,
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = when (bannerTier) {
AttentionTier.SILENT_HAPTIC -> Icons.Default.Notifications
AttentionTier.BACKGROUND_NOTIFICATION -> Icons.Default.Info
AttentionTier.OVERRIDE_SCREEN -> Icons.Default.Warning
else -> Icons.Default.Notifications
},
contentDescription = "Alert",
tint = Color.White
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "ATTENTION TARGET: ${bannerTier?.name}",
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 11.sp
)
Text(
text = bannerMessage ?: "",
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
if (bannerTier != AttentionTier.OVERRIDE_SCREEN) {
IconButton(onClick = { showBanner = false }) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
}
}
}
}
}
Text(
text = "SIMULATOR DEVICE: $deviceType",
color = TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// Device frame rendering
if (deviceType == "WEAR_OS_WATCH") {
// Round watch 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
) {
if (activePayload != null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
MeatbagComponentHost(
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
handleComponentIntent(
intent = intent,
localMockPayload = localMockPayload,
client = client,
onMockResolved = { localMockPayload = null },
scope = scope
)
}
)
}
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
"Watch Idle",
color = TextSecondary,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Text(
"Awaiting payload...",
color = TextSecondary.copy(alpha = 0.6f),
fontSize = 11.sp
)
}
}
}
} else {
// Phone / Desktop rectangular frame
val frameWidth = if (deviceType == "PHONE") 340.dp else 460.dp
val frameHeight = if (deviceType == "PHONE") 580.dp else 360.dp
Box(
modifier = Modifier
.size(width = frameWidth, height = frameHeight)
.clip(RoundedCornerShape(24.dp))
.background(SpaceDark)
.border(6.dp, Color(0xFF2C2F3E), RoundedCornerShape(24.dp))
.border(1.dp, AccentPurple, RoundedCornerShape(24.dp))
.padding(16.dp),
contentAlignment = Alignment.Center
) {
if (activePayload != null) {
MeatbagComponentHost(
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
handleComponentIntent(
intent = intent,
localMockPayload = localMockPayload,
client = client,
onMockResolved = { localMockPayload = null },
scope = scope
)
}
)
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(36.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
"$deviceType Idle",
color = TextSecondary,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
Text(
"Waiting for Ktor WebSocket payload",
color = TextSecondary.copy(alpha = 0.5f),
fontSize = 12.sp
)
}
}
}
}
}
// COLUMN 3: EVENT LOGS
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),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Event Console Logs",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Button(
onClick = { logs.clear() },
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.height(30.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text("Clear", color = TextSecondary, fontSize = 11.sp)
}
}
// Logs list view
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(ConsoleDark)
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(8.dp))
.padding(8.dp)
) {
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(logs) { log ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "[${log.timestamp}]",
color = TextSecondary,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
val typeText = when (log.type) {
LogType.INFO -> "INFO"
LogType.SUCCESS -> "SUCCESS"
LogType.WARNING -> "WARN"
LogType.ERROR -> "ERROR"
LogType.HAPTIC -> "HAPTIC"
}
val badgeColor = when (log.type) {
LogType.INFO -> TextSecondary
LogType.SUCCESS -> ActiveGreen
LogType.WARNING -> AlertOrange
LogType.ERROR -> DangerRed
LogType.HAPTIC -> AccentPurple
}
Text(
text = "$typeText:",
color = badgeColor,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.width(60.dp)
)
Text(
text = log.text,
color = Color.White,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
package meatbag.client
import meatbag.common.AttentionTier
class WearAttentionFeedback(
private val trigger: suspend (WearHapticPattern) -> Unit
) : AttentionFeedback {
override suspend fun onAttentionTier(tier: AttentionTier) {
trigger(tier.toWearHapticPattern())
}
}
enum class WearHapticPattern {
DoubleShort,
Notification,
UrgentLoop
}
fun AttentionTier.toWearHapticPattern(): WearHapticPattern {
return when (this) {
AttentionTier.SILENT_HAPTIC -> WearHapticPattern.DoubleShort
AttentionTier.BACKGROUND_NOTIFICATION -> WearHapticPattern.Notification
AttentionTier.OVERRIDE_SCREEN -> WearHapticPattern.UrgentLoop
}
}