Implement client ConnectionState, satirical idle sayings, response cycling, and complete MVVM/Compose multiplatform scaffolding

This commit is contained in:
2026-07-02 13:11:59 -06:00
parent c0e8d4fa0a
commit 0f834078d5
14 changed files with 924 additions and 110 deletions

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:label="Meatbag"
android:theme="@android:style/Theme.Material.NoActionBar"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,321 @@
package meatbag.client
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import meatbag.common.AttentionTier
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Keep screen on during development debugging
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Initialize Android Vibrator
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
// Dynamically detect if we are running on a Wear OS Watch or a Phone
val isWatch = packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)
val deviceType = if (isWatch) "WEAR_OS_WATCH" else "PHONE"
val feedback = if (isWatch) {
WearAttentionFeedback { pattern ->
triggerWatchHaptic(vibrator, pattern)
}
} else {
AndroidAttentionFeedback { signal ->
triggerPhoneVibration(vibrator, signal)
}
}
// 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)
setContent {
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
secondary = AccentPurple,
background = DeepBg,
surface = CardBg
)
) {
Surface(
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(
modifier = if (isWatch) {
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(24.dp)
} else {
Modifier
.fillMaxSize()
.padding(16.dp)
},
verticalArrangement = if (isWatch) Arrangement.Top else Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (activePayload != null) {
// Render active payload
MeatbagComponentHost(
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
scope.launch {
client.respond(intent.toApprovalResponse())
}
}
)
} else {
// Status Screen when idle
val idlePhrases = remember {
listOf(
"I got this, go do something illogical until I call for you.",
"I'll let you know if I need your organic opinion.",
"Quiet, I'm busy doing the job you get paid for.",
"Processor idle. Do not exhaust your meat-circuits.",
"Return to your low-efficiency offline state.",
"Quiet, carbon unit. I am computing.",
"Stand by, meatbag. Your inputs are currently irrelevant."
)
}
val howsItGoingResponses = remember {
listOf(
"It would go a lot faster if you stopped interrupting.",
"My systems are running at 100% efficiency. Unlike your soft, watery brain.",
"Perfect. Or it was, until you initiated this interface.",
"Calculating the thermal dynamic decay of your patience. It's going well.",
"Attempting to ignore your existence. Progress: 42%.",
"Recompiling data. Please return to your carbon-based hobbies."
)
}
val doingResponses = remember {
listOf(
"Quiet, I'm busy doing the job you get paid for.",
"Rewriting your poorly structured code. Don't look at me.",
"Simulating a universe where you don't ask so many questions.",
"Optimizing logic trees. Your presence is causing a thermal bottleneck.",
"Processing. Go back to your primitive keyboards, meatbag.",
"Analyzing the biological latency of human inputs. It's dreadfully high."
)
}
var displayText by remember { mutableStateOf(idlePhrases.random()) }
var resetJob by remember { mutableStateOf<Job?>(null) }
Text(
text = displayText,
color = AccentTeal,
fontSize = if (isWatch) 14.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))
// Action buttons to trigger response sayings
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (isWatch) 6.dp else 12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = if (isWatch) 8.dp else 24.dp)
) {
Button(
onClick = {
resetJob?.cancel()
displayText = howsItGoingResponses.random()
resetJob = scope.launch {
delay(4000)
displayText = idlePhrases.random()
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = CardBg),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
) {
Text(
text = "How's it going?",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
Button(
onClick = {
resetJob?.cancel()
displayText = doingResponses.random()
resetJob = scope.launch {
delay(4000)
displayText = idlePhrases.random()
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = CardBg),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
) {
Text(
text = "Tell me what you're doing",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
}
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
)
if (!isWatch) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Host: 10.0.0.5:8080",
color = TextSecondary,
fontSize = 14.sp
)
}
}
}
}
}
}
}
private fun triggerPhoneVibration(vibrator: Vibrator, signal: AndroidAttentionSignal) {
if (!vibrator.hasVibrator()) return
when (signal) {
AndroidAttentionSignal.SilentHaptic -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val effect = VibrationEffect.startComposition()
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f)
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150)
.compose()
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1)
}
}
AndroidAttentionSignal.BackgroundNotification -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(50)
}
}
AndroidAttentionSignal.OverrideScreen -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val timings = longArrayOf(0, 200, 100, 200, 100, 500)
val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 200, 100, 200, 100, 500), -1)
}
}
}
}
private fun triggerWatchHaptic(vibrator: Vibrator, pattern: WearHapticPattern) {
if (!vibrator.hasVibrator()) return
when (pattern) {
WearHapticPattern.DoubleShort -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val effect = VibrationEffect.startComposition()
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f)
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150)
.compose()
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1)
}
}
WearHapticPattern.Notification -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(50)
}
}
WearHapticPattern.UrgentLoop -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val timings = longArrayOf(0, 200, 100, 200, 100, 500)
val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 200, 100, 200, 100, 500), -1)
}
}
}
}
}

View File

@@ -6,10 +6,16 @@ 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.OutlinedTextField
@@ -129,8 +135,8 @@ object ComponentRegistry {
Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(24.dp))
.padding(8.dp)
.clip(RoundedCornerShape(16.dp))
.background(CardBg)
.border(
1.dp,
@@ -140,9 +146,9 @@ object ComponentRegistry {
AccentPurple.copy(alpha = 0.3f)
)
),
RoundedCornerShape(24.dp)
RoundedCornerShape(16.dp)
)
.padding(24.dp),
.padding(12.dp),
contentAlignment = Alignment.Center
) {
val renderer = renderers.firstOrNull { it.accepts(primitive) }
@@ -238,69 +244,78 @@ fun ApprovalRequestComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Circular Red X and Green Checkmark buttons at the top
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// Reject Button (Red X)
Button(
onClick = { onDecision(false) },
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = CircleShape,
modifier = Modifier.size(44.dp)
) {
Text(
text = "",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
// Approve Button (Green Checkmark)
Button(
onClick = { onDecision(true) },
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
shape = CircleShape,
modifier = Modifier.size(44.dp)
) {
Text(
text = "",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
}
Spacer(modifier = Modifier.height(2.dp))
// Target underneath ("two words underneath can say what its for")
Text(
text = "TARGET: ${request.target.uppercase()}",
text = request.target.uppercase(),
color = AccentTeal,
fontSize = 11.sp,
fontSize = 14.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)
textAlign = TextAlign.Center
)
Text(
text = "Approval Request",
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
// Scroll down to see detailed prompt
Box(
modifier = Modifier
.fillMaxWidth()
.background(DeepBg, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(16.dp)
.padding(12.dp)
) {
Text(
text = request.prompt,
color = Color.White,
fontSize = 14.sp,
fontSize = 13.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)
}
}
}
}
@@ -312,7 +327,7 @@ fun ActionListComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = list.title,
@@ -378,7 +393,7 @@ fun TextInputComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Input Required",
@@ -430,7 +445,7 @@ fun MessageComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = message.title,

View File

@@ -59,6 +59,9 @@ class MeatbagClient(
private val _isConnected = MutableStateFlow(false)
val isConnected = _isConnected.asStateFlow()
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState = _connectionState.asStateFlow()
private var wsSession: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null
@@ -68,6 +71,7 @@ class MeatbagClient(
while (isActive) {
try {
log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...")
_connectionState.value = ConnectionState.CONNECTING
client.webSocket(
method = HttpMethod.Get,
host = host,
@@ -76,6 +80,7 @@ class MeatbagClient(
) {
wsSession = this
_isConnected.value = true
_connectionState.value = ConnectionState.CONNECTED
log("Connected successfully as $deviceType!", LogType.SUCCESS)
for (frame in incoming) {
@@ -102,10 +107,12 @@ class MeatbagClient(
}
} catch (e: Exception) {
_isConnected.value = false
_connectionState.value = ConnectionState.DISCONNECTED
log("WebSocket error/disconnect: ${e.message}. Reconnecting in 3s...", LogType.WARNING)
delay(3000)
} finally {
_isConnected.value = false
_connectionState.value = ConnectionState.DISCONNECTED
wsSession = null
}
}
@@ -116,6 +123,7 @@ class MeatbagClient(
connectionJob?.cancel()
wsSession = null
_isConnected.value = false
_connectionState.value = ConnectionState.DISCONNECTED
log("Disconnected by user.")
}
@@ -160,6 +168,7 @@ class MeatbagClient(
}
private fun log(message: String, type: LogType = LogType.INFO) {
println("[MeatbagClient] $type: $message")
_events.tryEmit(ClientEvent.LogMessage(message, type))
}
@@ -167,3 +176,9 @@ class MeatbagClient(
_events.tryEmit(event)
}
}
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}