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

@@ -109,6 +109,22 @@ fun Application.module() {
deviceRegistry.register(device) deviceRegistry.register(device)
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType") println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
// Send requested profile layout immediately if specified
val profileId = call.parameters["profileId"]
if (profileId != null) {
val profile = profileStore.getProfile(profileId)
if (profile != null) {
val payload = WebSocketPayload(
attentionTier = AttentionTier.SILENT_HAPTIC,
uiPrimitive = profile.layout
)
send(Frame.Text(SDUIJson.json.encodeToString(payload)))
println("[WebSocket] Pushed initial profile layout '$profileId' to device $deviceId")
} else {
println("[WebSocket] Requested profile '$profileId' was not found in ProfileStore")
}
}
// Send pairing success indicator screen only if we actually paired in this session // Send pairing success indicator screen only if we actually paired in this session
if (showedPairingScreen) { if (showedPairingScreen) {
val successMessage = Message( val successMessage = Message(

View File

@@ -10,7 +10,7 @@ This document outlines the core product milestones, release phases, and sprint m
+-----------------------------------------------------------------------------------+ +-----------------------------------------------------------------------------------+
| PHASE 1: CLIENT AND BUILDER SOLIDIFICATION (Month 1) | | PHASE 1: CLIENT AND BUILDER SOLIDIFICATION (Month 1) |
| Focus: Core hardware shells (watches, phones) and responsive visual layout editor.| | Focus: Core hardware shells (watches, phones) and responsive visual layout editor.|
| Sprints: Sprint 1 (Polish) and Sprint 2 (Responsive Builder) | | Sprints: Sprint 1 (Polish), Sprint 1.5 (Dynamic Shortcuts), and Sprint 2 (Responsive Builder) |
+-----------------------------------------------------------------------------------+ +-----------------------------------------------------------------------------------+
| |
v v
@@ -36,6 +36,8 @@ This document outlines the core product milestones, release phases, and sprint m
* Run UX research prompt, select product name, verify domains, establish brand style guide (color palette, typography tokens, tagline options). * Run UX research prompt, select product name, verify domains, establish brand style guide (color palette, typography tokens, tagline options).
* **Sprint 1: Native Wearable & Mobile Polish** * **Sprint 1: Native Wearable & Mobile Polish**
* Hardware performance validation, smartwatch AOD optimization, background WebSocket connection reliability, and haptic overrides. * Hardware performance validation, smartwatch AOD optimization, background WebSocket connection reliability, and haptic overrides.
* **Sprint 1.5: Dynamic Shortcuts & Dynamic Branding (Mobile Launcher Routing)**
* Pin dynamic launcher shortcuts for marketplace profiles on Android/iOS. Implement intent-based startup routing and dynamic theme overrides in client shells.
* **Sprint 2: Builder Porting & Responsive Design** * **Sprint 2: Builder Porting & Responsive Design**
* Migrate visual editor to `:commonMain`. Design the responsive split-screen phone view. Implement **Developer Mode (Tabs)** and **Easy Mode (Wizard)**. * Migrate visual editor to `:commonMain`. Design the responsive split-screen phone view. Implement **Developer Mode (Tabs)** and **Easy Mode (Wizard)**.
* **Sprint 3: Local Sync API & Database Caching** * **Sprint 3: Local Sync API & Database Caching**

View File

@@ -74,6 +74,24 @@ Optimize the stateless client shells on physical Android S23 Ultra and Wear OS W
--- ---
## Sprint 1.5: Dynamic Shortcuts & Dynamic Branding (Mobile Launcher Routing)
### Objectives
Enable the single pre-compiled client shell to behave like separate, standalone apps for different marketplace profiles by dynamically pinning launcher shortcuts and routing intent payloads.
### Task List
1. **ShortcutManager Integration**: Implement native Android `ShortcutManager` calls in `MainActivity` to request pinning shortcuts with custom names and icons.
2. **Intent Launch Routing**: Update `MainActivity` entry point to intercept `LAUNCH_PROFILE_ID` extra, skipping the manager settings dashboard.
3. **Dynamic Theme Styling**: Map loaded profile IDs to dynamic theme color palettes (e.g. Purple for LM Studio, Teal for Meatbag).
4. **Local Dashboard Tiles**: Build a tile selector in the disconnected manager interface listing installed profiles with options to "Launch" or "Pin Tile".
### Acceptance Criteria
* Clicking "Pin Tile" displays the Android system shortcut confirmation dialog.
* Launching the app via pinned shortcut bypasses settings and connects automatically.
* Toggling between profiles dynamically changes the application primary/secondary theme colors.
---
## Sprint 2: Builder Porting & Responsive Design ## Sprint 2: Builder Porting & Responsive Design
### Objectives ### Objectives

View File

@@ -31,6 +31,39 @@ import meatbag.common.AttentionTier
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -64,7 +97,7 @@ class MainActivity : ComponentActivity() {
val prefs = getSharedPreferences("meatbag_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("meatbag_prefs", Context.MODE_PRIVATE)
setContent { 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 portStr by remember { mutableStateOf(prefs.getString("port", "8080") ?: "8080") }
var authKey by remember { mutableStateOf(prefs.getString("auth_key", "") ?: "") } var authKey by remember { mutableStateOf(prefs.getString("auth_key", "") ?: "") }
@@ -76,11 +109,47 @@ class MainActivity : ComponentActivity() {
val context = this val context = this
var errorMessage by remember { mutableStateOf<String?>(null) } 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( MaterialTheme(
colors = darkColors( colors = darkColors(
primary = AccentTeal, primary = themePrimary,
secondary = AccentPurple, secondary = themeSecondary,
background = DeepBg, background = themeBackground,
surface = CardBg surface = CardBg
) )
) { ) {
@@ -231,6 +300,7 @@ class MainActivity : ComponentActivity() {
clientState?.disconnect() clientState?.disconnect()
TailscaleUserspace.stop() TailscaleUserspace.stop()
clientState = null clientState = null
activeProfileId = null
}, },
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed), colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp), shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
@@ -248,14 +318,26 @@ class MainActivity : ComponentActivity() {
} }
} else { } else {
// Settings & Connection Panel // Settings & Connection Panel
val appTitle = when (activeProfileId) {
"lm_studio_remote" -> "LM Studio Lite"
"meatbag" -> "Meatbag Agent"
else -> "WhetForge Hub"
}
Text( Text(
text = "MEATBAG", text = appTitle,
color = AccentTeal, color = themePrimary,
fontSize = if (isWatch) 16.sp else 24.sp, fontSize = if (isWatch) 16.sp else 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h5 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)) Spacer(modifier = Modifier.height(6.dp))
Text( Text(
@@ -326,7 +408,7 @@ class MainActivity : ComponentActivity() {
socksPort = socksPort socksPort = socksPort
) )
clientState = newClient clientState = newClient
newClient.connect(deviceType) newClient.connect(deviceType, activeProfileId)
} }
}, },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple), colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
@@ -354,6 +436,106 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.padding(horizontal = 4.dp) 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 wsSession: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null private var connectionJob: Job? = null
fun connect(deviceType: String) { fun connect(deviceType: String, profileId: String? = null) {
connectionJob?.cancel() connectionJob?.cancel()
connectionJob = CoroutineScope(Dispatchers.Default).launch { connectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isActive) { while (isActive) {
try { 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 _connectionState.value = ConnectionState.CONNECTING
client.webSocket( client.webSocket(
method = HttpMethod.Get, method = HttpMethod.Get,
host = host, host = host,
port = port, port = port,
path = "/ws/connect?deviceType=$deviceType" path = "/ws/connect$queryParams"
) { ) {
wsSession = this wsSession = this
_isConnected.value = true _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) { private fun log(message: String, type: LogType = LogType.INFO) {
println("[MeatbagClient] $type: $message") println("[MeatbagClient] $type: $message")
_events.tryEmit(ClientEvent.LogMessage(message, type)) _events.tryEmit(ClientEvent.LogMessage(message, type))

View File

@@ -0,0 +1,28 @@
{
"id": "lm_studio_remote",
"name": "LM Studio Lite",
"description": "Control local LLMs running on your desktop PC.",
"layout": {
"type": "ActionList",
"id": "lm_controls",
"title": "LM Studio Controller",
"subtitle": "Active Model: Qwen 2.5 7B\nTemperature: 0.7",
"actions": [
{
"id": "open_prompt_input",
"label": "✍️ Enter Chat Prompt",
"intent": "SUBMIT"
},
{
"id": "refresh_models",
"label": "🔄 Refresh Model List",
"intent": "CUSTOM"
},
{
"id": "toggle_temp",
"label": "🌡️ Adjust Temperature",
"intent": "CUSTOM"
}
]
}
}

12
profiles/meatbag.json Normal file
View File

@@ -0,0 +1,12 @@
{
"id": "meatbag",
"name": "Meatbag Agent",
"description": "AI Agent confirmation prompts & haptic alerts.",
"layout": {
"type": "ApprovalRequest",
"id": "meatbag_connect_idle",
"prompt": "Connected and idle. Awaiting agent directives...",
"target": "system",
"tone": "INFO"
}
}