Implement client ConnectionState, satirical idle sayings, response cycling, and complete MVVM/Compose multiplatform scaffolding
This commit is contained in:
@@ -2,7 +2,7 @@ plugins {
|
||||
kotlin("multiplatform")
|
||||
id("org.jetbrains.compose")
|
||||
kotlin("plugin.serialization")
|
||||
id("com.android.library")
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -65,7 +65,16 @@ android {
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "meatbag.client"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
|
||||
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
client/src/androidMain/AndroidManifest.xml
Normal file
25
client/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||
321
client/src/androidMain/kotlin/meatbag/client/MainActivity.kt
Normal file
321
client/src/androidMain/kotlin/meatbag/client/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user