diff --git a/backend/src/main/kotlin/meatbag/backend/Application.kt b/backend/src/main/kotlin/meatbag/backend/Application.kt index 1902a0d..af31592 100644 --- a/backend/src/main/kotlin/meatbag/backend/Application.kt +++ b/backend/src/main/kotlin/meatbag/backend/Application.kt @@ -109,6 +109,22 @@ fun Application.module() { deviceRegistry.register(device) 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 if (showedPairingScreen) { val successMessage = Message( diff --git a/business/product_roadmap.md b/business/product_roadmap.md index 648f087..1e750e1 100644 --- a/business/product_roadmap.md +++ b/business/product_roadmap.md @@ -10,7 +10,7 @@ This document outlines the core product milestones, release phases, and sprint m +-----------------------------------------------------------------------------------+ | PHASE 1: CLIENT AND BUILDER SOLIDIFICATION (Month 1) | | 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 @@ -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). * **Sprint 1: Native Wearable & Mobile Polish** * 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** * 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** diff --git a/business/sprint_specifications.md b/business/sprint_specifications.md index 1693bf1..cc01aca 100644 --- a/business/sprint_specifications.md +++ b/business/sprint_specifications.md @@ -55,7 +55,7 @@ Please perform a comprehensive UX Research and Branding Assessment to determine ``` --- - + ## Sprint 1: Native Wearable & Mobile Polish ### Objectives @@ -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 ### Objectives diff --git a/client/src/androidMain/kotlin/meatbag/client/MainActivity.kt b/client/src/androidMain/kotlin/meatbag/client/MainActivity.kt index ce074df..65d7632 100644 --- a/client/src/androidMain/kotlin/meatbag/client/MainActivity.kt +++ b/client/src/androidMain/kotlin/meatbag/client/MainActivity.kt @@ -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(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) + } + } + } + } + } } } } diff --git a/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt b/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt index e324e6b..cdffc2d 100644 --- a/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt +++ b/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt @@ -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 { + 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)) diff --git a/profiles/lm_studio_remote.json b/profiles/lm_studio_remote.json new file mode 100644 index 0000000..eccfd25 --- /dev/null +++ b/profiles/lm_studio_remote.json @@ -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" + } + ] + } +} diff --git a/profiles/meatbag.json b/profiles/meatbag.json new file mode 100644 index 0000000..e544fc7 --- /dev/null +++ b/profiles/meatbag.json @@ -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" + } +}