feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user