feat: implement dynamic launcher shortcuts, launch intent routing, dynamic theme color overrides, and initial profiles

This commit is contained in:
2026-07-04 14:23:51 -06:00
parent 17b0ccf464
commit 7fe1eda75b
7 changed files with 305 additions and 12 deletions

View File

@@ -31,6 +31,39 @@ import meatbag.common.AttentionTier
class MainActivity : ComponentActivity() {
private var onProfileLaunch: ((String?) -> Unit)? = null
override fun onNewIntent(intent: android.content.Intent?) {
super.onNewIntent(intent)
val profileId = intent?.getStringExtra("LAUNCH_PROFILE_ID")
onProfileLaunch?.invoke(profileId)
}
private fun pinShortcutForProfile(profileId: String, profileName: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val shortcutManager = getSystemService(android.content.pm.ShortcutManager::class.java)
if (shortcutManager != null && shortcutManager.isRequestPinShortcutSupported) {
val intent = android.content.Intent(this, MainActivity::class.java).apply {
action = android.content.Intent.ACTION_VIEW
putExtra("LAUNCH_PROFILE_ID", profileId)
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val shortcut = android.content.pm.ShortcutInfo.Builder(this, profileId)
.setShortLabel(profileName)
.setLongLabel("Launch $profileName")
.setIcon(android.graphics.drawable.Icon.createWithResource(this, applicationInfo.icon))
.setIntent(intent)
.build()
val successIntent = android.content.Intent("com.whetforge.shortcut.PINNED")
val successPendingIntent = android.app.PendingIntent.getBroadcast(
this, 0, successIntent, android.app.PendingIntent.FLAG_IMMUTABLE
)
shortcutManager.requestPinShortcut(shortcut, successPendingIntent.intentSender)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -64,7 +97,7 @@ class MainActivity : ComponentActivity() {
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 host by remember { mutableStateOf(prefs.getString("host", "10.0.0.7") ?: "10.0.0.7") }
var portStr by remember { mutableStateOf(prefs.getString("port", "8080") ?: "8080") }
var authKey by remember { mutableStateOf(prefs.getString("auth_key", "") ?: "") }
@@ -76,11 +109,47 @@ class MainActivity : ComponentActivity() {
val context = this
var errorMessage by remember { mutableStateOf<String?>(null) }
var activeProfileId by remember { mutableStateOf(intent.getStringExtra("LAUNCH_PROFILE_ID")) }
LaunchedEffect(Unit) {
onProfileLaunch = { id ->
activeProfileId = id
}
}
// Auto-connect when a specific profile is launched via shortcut or intent
LaunchedEffect(activeProfileId) {
if (activeProfileId != null && connectionState == ConnectionState.DISCONNECTED && clientState == null) {
errorMessage = null
scope.launch(Dispatchers.Default) {
val newClient = MeatbagClient(host.trim(), portStr.trim().toIntOrNull() ?: 8080, feedback)
clientState = newClient
newClient.connect(deviceType, activeProfileId)
}
}
}
// Dynamic theme colors based on active profile
val themePrimary = when (activeProfileId) {
"lm_studio_remote" -> Color(0xFFBB86FC)
"meatbag" -> AccentTeal
else -> AccentTeal
}
val themeSecondary = when (activeProfileId) {
"lm_studio_remote" -> Color(0xFF03DAC6)
"meatbag" -> AccentPurple
else -> AccentPurple
}
val themeBackground = when (activeProfileId) {
"lm_studio_remote" -> Color(0xFF121212)
"meatbag" -> DeepBg
else -> DeepBg
}
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
secondary = AccentPurple,
background = DeepBg,
primary = themePrimary,
secondary = themeSecondary,
background = themeBackground,
surface = CardBg
)
) {
@@ -231,6 +300,7 @@ class MainActivity : ComponentActivity() {
clientState?.disconnect()
TailscaleUserspace.stop()
clientState = null
activeProfileId = null
},
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
@@ -248,14 +318,26 @@ class MainActivity : ComponentActivity() {
}
} else {
// Settings & Connection Panel
val appTitle = when (activeProfileId) {
"lm_studio_remote" -> "LM Studio Lite"
"meatbag" -> "Meatbag Agent"
else -> "WhetForge Hub"
}
Text(
text = "MEATBAG",
color = AccentTeal,
text = appTitle,
color = themePrimary,
fontSize = if (isWatch) 16.sp else 24.sp,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h5
)
if (activeProfileId != null && !isWatch) {
Spacer(modifier = Modifier.height(2.dp))
TextButton(onClick = { activeProfileId = null }) {
Text("← Exit to Hub Dashboard", color = themePrimary, fontSize = 12.sp)
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
@@ -326,7 +408,7 @@ class MainActivity : ComponentActivity() {
socksPort = socksPort
)
clientState = newClient
newClient.connect(deviceType)
newClient.connect(deviceType, activeProfileId)
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
@@ -354,6 +436,106 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.padding(horizontal = 4.dp)
)
}
if (activeProfileId == null && !isWatch) {
Spacer(modifier = Modifier.height(24.dp))
Divider(color = Color.White.copy(alpha = 0.1f))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "INSTALLED MARKETPLACE TILES",
color = TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)
Spacer(modifier = Modifier.height(10.dp))
// Profile 1: Meatbag Agent
Card(
backgroundColor = CardBg,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("Meatbag Agent", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
Text("AI Agent approvals & haptics", color = TextSecondary, fontSize = 12.sp)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
activeProfileId = "meatbag"
errorMessage = null
scope.launch(Dispatchers.Default) {
val newClient = MeatbagClient(host.trim(), portStr.trim().toIntOrNull() ?: 8080, feedback)
clientState = newClient
newClient.connect(deviceType, "meatbag")
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = AccentTeal),
shape = RoundedCornerShape(8.dp)
) {
Text("Launch", color = DeepBg, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
Button(
onClick = { pinShortcutForProfile("meatbag", "Meatbag Agent") },
colors = ButtonDefaults.buttonColors(backgroundColor = DeepBg),
shape = RoundedCornerShape(8.dp)
) {
Text("Pin Tile", color = Color.White, fontSize = 11.sp)
}
}
}
}
// Profile 2: LM Studio Lite
Card(
backgroundColor = CardBg,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("LM Studio Lite", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp)
Text("Control local LLM models", color = TextSecondary, fontSize = 12.sp)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
activeProfileId = "lm_studio_remote"
errorMessage = null
scope.launch(Dispatchers.Default) {
val newClient = MeatbagClient(host.trim(), portStr.trim().toIntOrNull() ?: 8080, feedback)
clientState = newClient
newClient.connect(deviceType, "lm_studio_remote")
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = Color(0xFFBB86FC)),
shape = RoundedCornerShape(8.dp)
) {
Text("Launch", color = Color.Black, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
Button(
onClick = { pinShortcutForProfile("lm_studio_remote", "LM Studio Lite") },
colors = ButtonDefaults.buttonColors(backgroundColor = DeepBg),
shape = RoundedCornerShape(8.dp)
) {
Text("Pin Tile", color = Color.White, fontSize = 11.sp)
}
}
}
}
}
}
}
}

View File

@@ -71,18 +71,23 @@ class MeatbagClient(
private var wsSession: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null
fun connect(deviceType: String) {
fun connect(deviceType: String, profileId: String? = null) {
connectionJob?.cancel()
connectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isActive) {
try {
log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...")
val queryParams = if (profileId != null) {
"?deviceType=$deviceType&profileId=$profileId"
} else {
"?deviceType=$deviceType"
}
log("Connecting to ws://$host:$port/ws/connect$queryParams ...")
_connectionState.value = ConnectionState.CONNECTING
client.webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/ws/connect?deviceType=$deviceType"
path = "/ws/connect$queryParams"
) {
wsSession = this
_isConnected.value = true
@@ -194,6 +199,36 @@ class MeatbagClient(
}
}
suspend fun getProfile(id: String): meatbag.common.UserProfile? {
return try {
val response = client.get("http://$host:$port/api/profiles/$id")
if (response.status.value == 200) {
val jsonStr = response.bodyAsText()
jsonConfig.decodeFromString(meatbag.common.UserProfile.serializer(), jsonStr)
} else {
null
}
} catch (e: Exception) {
log("Error getting profile $id: ${e.message}", LogType.ERROR)
null
}
}
suspend fun listProfiles(): List<meatbag.common.UserProfile> {
return try {
val response = client.get("http://$host:$port/api/profiles")
if (response.status.value == 200) {
val jsonStr = response.bodyAsText()
jsonConfig.decodeFromString(kotlinx.serialization.builtins.ListSerializer(meatbag.common.UserProfile.serializer()), jsonStr)
} else {
emptyList()
}
} catch (e: Exception) {
log("Error listing profiles: ${e.message}", LogType.ERROR)
emptyList()
}
}
private fun log(message: String, type: LogType = LogType.INFO) {
println("[MeatbagClient] $type: $message")
_events.tryEmit(ClientEvent.LogMessage(message, type))