feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps

This commit is contained in:
2026-07-04 13:46:24 -06:00
parent 0f834078d5
commit 17b0ccf464
26 changed files with 2940 additions and 64 deletions

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -59,17 +60,22 @@ class MainActivity : ComponentActivity() {
}
}
// Setup client
val client = MeatbagClient(
host = "10.0.0.5", // Your PC's local Wi-Fi IP
port = 8080,
attentionFeedback = feedback
)
// Connect client
client.connect(deviceType)
// SharedPreferences for connection details
val prefs = getSharedPreferences("meatbag_prefs", Context.MODE_PRIVATE)
setContent {
var host by remember { mutableStateOf(prefs.getString("host", "10.0.0.5") ?: "10.0.0.5") }
var portStr by remember { mutableStateOf(prefs.getString("port", "8080") ?: "8080") }
var authKey by remember { mutableStateOf(prefs.getString("auth_key", "") ?: "") }
var clientState by remember { mutableStateOf<MeatbagClient?>(null) }
val activePayload = clientState?.currentPayload?.collectAsState()?.value
val connectionState = clientState?.connectionState?.collectAsState()?.value ?: ConnectionState.DISCONNECTED
val scope = rememberCoroutineScope()
val context = this
var errorMessage by remember { mutableStateOf<String?>(null) }
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
@@ -82,10 +88,6 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val scope = rememberCoroutineScope()
val activePayload by client.currentPayload.collectAsState()
val connectionState by client.connectionState.collectAsState()
val scrollState = rememberScrollState()
Column(
@@ -93,7 +95,7 @@ class MainActivity : ComponentActivity() {
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(24.dp)
.padding(horizontal = 16.dp, vertical = 24.dp)
} else {
Modifier
.fillMaxSize()
@@ -108,12 +110,12 @@ class MainActivity : ComponentActivity() {
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
scope.launch {
client.respond(intent.toApprovalResponse())
clientState?.respond(intent.toApprovalResponse())
}
}
)
} else {
// Status Screen when idle
} else if (connectionState == ConnectionState.CONNECTED) {
// Status Screen when connected and idle
val idlePhrases = remember {
listOf(
"I got this, go do something illogical until I call for you.",
@@ -152,13 +154,13 @@ class MainActivity : ComponentActivity() {
Text(
text = displayText,
color = AccentTeal,
fontSize = if (isWatch) 14.sp else 18.sp,
fontSize = if (isWatch) 13.sp else 18.sp,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
Spacer(modifier = Modifier.height(if (isWatch) 10.dp else 24.dp))
// Action buttons to trigger response sayings
Column(
@@ -179,12 +181,12 @@ class MainActivity : ComponentActivity() {
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
.height(if (isWatch) 32.dp else 48.dp)
) {
Text(
text = "How's it going?",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontSize = if (isWatch) 10.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
@@ -202,12 +204,12 @@ class MainActivity : ComponentActivity() {
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
.height(if (isWatch) 32.dp else 48.dp)
) {
Text(
text = "Tell me what you're doing",
text = "Tell me what I'm doing",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontSize = if (isWatch) 10.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
@@ -215,27 +217,141 @@ class MainActivity : ComponentActivity() {
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
val statusText = when (connectionState) {
ConnectionState.CONNECTED -> "CONNECTED"
ConnectionState.CONNECTING -> "CONNECTING..."
ConnectionState.DISCONNECTED -> "DISCONNECTED"
}
val statusColor = when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> TextSecondary
ConnectionState.DISCONNECTED -> DangerRed
}
Text(
text = statusText,
color = statusColor,
fontSize = if (isWatch) 12.sp else 16.sp
text = "CONNECTED",
color = SuccessGreen,
fontSize = if (isWatch) 12.sp else 16.sp,
fontWeight = FontWeight.Bold
)
if (!isWatch) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
clientState?.disconnect()
TailscaleUserspace.stop()
clientState = null
},
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 32.dp else 48.dp)
.padding(horizontal = if (isWatch) 8.dp else 24.dp)
) {
Text(
text = "Disconnect",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
}
} else {
// Settings & Connection Panel
Text(
text = "MEATBAG",
color = AccentTeal,
fontSize = if (isWatch) 16.sp else 24.sp,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h5
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = if (connectionState == ConnectionState.CONNECTING) "CONNECTING..." else "DISCONNECTED",
color = if (connectionState == ConnectionState.CONNECTING) TextSecondary else DangerRed,
fontSize = if (isWatch) 11.sp else 14.sp
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host", fontSize = if (isWatch) 10.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port", fontSize = if (isWatch) 10.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = authKey,
onValueChange = { authKey = it },
label = { Text("Tailscale Auth Key (optional)", fontSize = if (isWatch) 9.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
errorMessage = null
// Save preferences
prefs.edit().apply {
putString("host", host)
putString("port", portStr)
putString("auth_key", authKey)
apply()
}
scope.launch(Dispatchers.Default) {
var socksPort: Int? = null
if (authKey.isNotBlank()) {
val tsResult = TailscaleUserspace.start(context, authKey.trim())
if (tsResult.isSuccess) {
socksPort = tsResult.getOrNull()
} else {
errorMessage = tsResult.exceptionOrNull()?.message
return@launch
}
}
val newClient = MeatbagClient(
host = host.trim(),
port = portStr.trim().toIntOrNull() ?: 8080,
attentionFeedback = feedback,
socksPort = socksPort
)
clientState = newClient
newClient.connect(deviceType)
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp),
enabled = connectionState == ConnectionState.DISCONNECTED
) {
Text(
text = "Connect",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
}
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Host: 10.0.0.5:8080",
color = TextSecondary,
fontSize = 14.sp
text = errorMessage!!,
color = DangerRed,
fontSize = 10.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}

View File

@@ -0,0 +1,34 @@
package meatbag.client
import android.content.Context
import com.tailscale.wearos.TailscaleConnection
object TailscaleUserspace {
private var runningPort: Int? = null
fun isRunning(): Boolean = runningPort != null
fun getPort(): Int? = runningPort
/**
* Starts the embedded Tailscale proxy in userspace mode.
* Returns the local SOCKS5 proxy port if successful.
*/
fun start(context: Context, authKey: String): Result<Int> {
if (runningPort != null) return Result.success(runningPort!!)
val result = TailscaleConnection.start(context, authKey.trim())
if (result.isSuccess) {
runningPort = result.getOrNull()
}
return result
}
/**
* Stops the running Tailscale proxy
*/
fun stop() {
if (runningPort == null) return
TailscaleConnection.stop()
runningPort = null
}
}