feat: implement dynamic launcher shortcuts, launch intent routing, dynamic theme color overrides, and initial profiles
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
28
profiles/lm_studio_remote.json
Normal file
28
profiles/lm_studio_remote.json
Normal 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
12
profiles/meatbag.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user