Initialize Meatbag SDUI platform
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
476
client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt
Normal file
476
client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
169
client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt
Normal file
169
client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package meatbag.client
|
||||
|
||||
import meatbag.common.AttentionTier
|
||||
|
||||
class DesktopAttentionFeedback : AttentionFeedback {
|
||||
override suspend fun onAttentionTier(tier: AttentionTier) = Unit
|
||||
}
|
||||
658
client/src/desktopMain/kotlin/meatbag/client/Main.kt
Normal file
658
client/src/desktopMain/kotlin/meatbag/client/Main.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user