feat(android): add overlay command panel, dismiss target, vignette, voice integration, and theme updates

- Add CommandPanelOverlay with suggestion cards and text input for goals
- Add DismissTargetView for drag-to-dismiss floating pill
- Add VignetteOverlay for crimson glow during agent execution
- Integrate voice mic button in command panel
- Add VoiceInteractionService for system assistant registration
- Store recent goals in DataStore for command panel suggestions
- Update GradientBorder and VoiceOverlayContent to DroidClaw crimson/golden theme
- Fix default assistant settings to use ACTION_VOICE_INPUT_SETTINGS
- Merge upstream voice overlay architecture with local overlay features
This commit is contained in:
Somasundaram Mahesh
2026-02-20 06:23:00 +05:30
parent 2411f47914
commit 474395e8c4
18 changed files with 1085 additions and 129 deletions

View File

@@ -28,13 +28,17 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.DroidClaw"> android:theme="@style/Theme.DroidClaw">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity> </activity>
<service <service
@@ -54,6 +58,24 @@
android:foregroundServiceType="connectedDevice" android:foregroundServiceType="connectedDevice"
android:exported="false" /> android:exported="false" />
<service
android:name=".voice.DroidClawVoiceInteractionService"
android:label="@string/app_name"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:exported="true">
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_service" />
</service>
<service
android:name=".voice.DroidClawVoiceSessionService"
android:permission="android.permission.BIND_VOICE_INTERACTION"
android:exported="true" />
</application> </application>
</manifest> </manifest>

View File

@@ -3,10 +3,12 @@ package com.thisux.droidclaw
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.thisux.droidclaw.connection.ConnectionService
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -75,6 +77,14 @@ class MainActivity : ComponentActivity() {
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} }
} }
override fun onResume() {
super.onResume()
val service = ConnectionService.instance ?: return
if (Settings.canDrawOverlays(this)) {
service.overlay?.show()
}
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View File

@@ -12,6 +12,7 @@ import com.thisux.droidclaw.model.PongMessage
import com.thisux.droidclaw.model.ResultResponse import com.thisux.droidclaw.model.ResultResponse
import com.thisux.droidclaw.model.ScreenResponse import com.thisux.droidclaw.model.ScreenResponse
import com.thisux.droidclaw.model.ServerMessage import com.thisux.droidclaw.model.ServerMessage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class CommandRouter( class CommandRouter(
@@ -27,6 +28,10 @@ class CommandRouter(
val currentGoal = MutableStateFlow("") val currentGoal = MutableStateFlow("")
val currentSessionId = MutableStateFlow<String?>(null) val currentSessionId = MutableStateFlow<String?>(null)
// Called before/after screen capture to hide/show overlays that would pollute the agent's view
var beforeScreenCapture: (() -> Unit)? = null
var afterScreenCapture: (() -> Unit)? = null
private var gestureExecutor: GestureExecutor? = null private var gestureExecutor: GestureExecutor? = null
fun updateGestureExecutor() { fun updateGestureExecutor() {
@@ -87,7 +92,7 @@ class CommandRouter(
} }
} }
private fun handleGetScreen(requestId: String) { private suspend fun handleGetScreen(requestId: String) {
updateGestureExecutor() updateGestureExecutor()
val svc = DroidClawAccessibilityService.instance val svc = DroidClawAccessibilityService.instance
val elements = svc?.getScreenTree() ?: emptyList() val elements = svc?.getScreenTree() ?: emptyList()
@@ -97,7 +102,11 @@ class CommandRouter(
var screenshot: String? = null var screenshot: String? = null
if (elements.isEmpty()) { if (elements.isEmpty()) {
// Hide overlays so the agent gets a clean screenshot
beforeScreenCapture?.invoke()
delay(150) // wait for virtual display to render a clean frame
val bytes = captureManager?.capture() val bytes = captureManager?.capture()
afterScreenCapture?.invoke()
if (bytes != null) { if (bytes != null) {
screenshot = Base64.encodeToString(bytes, Base64.NO_WRAP) screenshot = Base64.encodeToString(bytes, Base64.NO_WRAP)
} }

View File

@@ -147,6 +147,13 @@ class ConnectionService : LifecycleService() {
webSocket = ws webSocket = ws
val router = CommandRouter(ws, captureManager) val router = CommandRouter(ws, captureManager)
router.beforeScreenCapture = { overlay?.hideVignette() }
router.afterScreenCapture = {
if (currentGoalStatus.value == GoalStatus.Running &&
Settings.canDrawOverlays(this@ConnectionService)) {
overlay?.showVignette()
}
}
commandRouter = router commandRouter = router
launch { launch {
@@ -173,7 +180,24 @@ class ConnectionService : LifecycleService() {
} }
launch { ws.errorMessage.collect { errorMessage.value = it } } launch { ws.errorMessage.collect { errorMessage.value = it } }
launch { router.currentSteps.collect { currentSteps.value = it } } launch { router.currentSteps.collect { currentSteps.value = it } }
launch { router.currentGoalStatus.collect { currentGoalStatus.value = it } } launch {
router.currentGoalStatus.collect { status ->
currentGoalStatus.value = status
if (status == GoalStatus.Running) {
if (Settings.canDrawOverlays(this@ConnectionService)) {
overlay?.showVignette()
}
} else {
overlay?.hideVignette()
}
if (status == GoalStatus.Completed) {
val goal = router.currentGoal.value
if (goal.isNotBlank()) {
(application as DroidClawApp).settingsStore.addRecentGoal(goal)
}
}
}
}
launch { router.currentGoal.collect { currentGoal.value = it } } launch { router.currentGoal.collect { currentGoal.value = it } }
acquireWakeLock() acquireWakeLock()
@@ -206,6 +230,7 @@ class ConnectionService : LifecycleService() {
} }
private fun disconnect() { private fun disconnect() {
overlay?.hideVignette()
overlay?.hide() overlay?.hide()
webSocket?.disconnect() webSocket?.disconnect()
webSocket = null webSocket = null

View File

@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.json.JSONArray
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -18,6 +19,7 @@ object SettingsKeys {
val DEVICE_NAME = stringPreferencesKey("device_name") val DEVICE_NAME = stringPreferencesKey("device_name")
val AUTO_CONNECT = booleanPreferencesKey("auto_connect") val AUTO_CONNECT = booleanPreferencesKey("auto_connect")
val HAS_ONBOARDED = booleanPreferencesKey("has_onboarded") val HAS_ONBOARDED = booleanPreferencesKey("has_onboarded")
val RECENT_GOALS = stringPreferencesKey("recent_goals")
} }
class SettingsStore(private val context: Context) { class SettingsStore(private val context: Context) {
@@ -61,4 +63,25 @@ class SettingsStore(private val context: Context) {
suspend fun setHasOnboarded(value: Boolean) { suspend fun setHasOnboarded(value: Boolean) {
context.dataStore.edit { it[SettingsKeys.HAS_ONBOARDED] = value } context.dataStore.edit { it[SettingsKeys.HAS_ONBOARDED] = value }
} }
val recentGoals: Flow<List<String>> = context.dataStore.data.map { prefs ->
val json = prefs[SettingsKeys.RECENT_GOALS] ?: "[]"
try {
JSONArray(json).let { arr ->
(0 until arr.length()).map { arr.getString(it) }
}
} catch (_: Exception) { emptyList() }
}
suspend fun addRecentGoal(goal: String) {
context.dataStore.edit { prefs ->
val current = try {
JSONArray(prefs[SettingsKeys.RECENT_GOALS] ?: "[]").let { arr ->
(0 until arr.length()).map { arr.getString(it) }
}
} catch (_: Exception) { emptyList() }
val updated = (listOf(goal) + current.filter { it != goal }).take(5)
prefs[SettingsKeys.RECENT_GOALS] = JSONArray(updated).toString()
}
}
} }

View File

@@ -17,12 +17,16 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.thisux.droidclaw.MainActivity import com.thisux.droidclaw.MainActivity
import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.model.OverlayMode import com.thisux.droidclaw.model.OverlayMode
import com.thisux.droidclaw.ui.theme.DroidClawTheme import com.thisux.droidclaw.ui.theme.DroidClawTheme
class AgentOverlay(private val service: LifecycleService) { class AgentOverlay(private val service: LifecycleService) {
private val windowManager = service.getSystemService(WindowManager::class.java) private val windowManager = service.getSystemService(WindowManager::class.java)
private val dismissTarget = DismissTargetView(service)
private val vignetteOverlay = VignetteOverlay(service)
private val savedStateOwner = object : SavedStateRegistryOwner { private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this) private val controller = SavedStateRegistryController.create(this)
@@ -50,6 +54,20 @@ class AgentOverlay(private val service: LifecycleService) {
// ── Voice recorder ────────────────────────────────────── // ── Voice recorder ──────────────────────────────────────
private var voiceRecorder: VoiceRecorder? = null private var voiceRecorder: VoiceRecorder? = null
// ── Command panel ───────────────────────────────────────
private val commandPanel = CommandPanelOverlay(
service = service,
onSubmitGoal = { goal ->
val intent = Intent(service, ConnectionService::class.java).apply {
action = ConnectionService.ACTION_SEND_GOAL
putExtra(ConnectionService.EXTRA_GOAL, goal)
}
service.startService(intent)
},
onStartVoice = { startListening() },
onDismiss = { show() }
)
// ── Layout params ─────────────────────────────────────── // ── Layout params ───────────────────────────────────────
private val pillParams = WindowManager.LayoutParams( private val pillParams = WindowManager.LayoutParams(
@@ -93,14 +111,21 @@ class AgentOverlay(private val service: LifecycleService) {
fun hide() { fun hide() {
hidePill() hidePill()
hideVoiceOverlay() hideVoiceOverlay()
dismissTarget.hide()
} }
fun destroy() { fun destroy() {
hide() hide()
commandPanel.destroy()
vignetteOverlay.destroy()
voiceRecorder?.stop() voiceRecorder?.stop()
voiceRecorder = null voiceRecorder = null
} }
fun showVignette() = vignetteOverlay.show()
fun hideVignette() = vignetteOverlay.hide()
fun startListening() { fun startListening() {
val recorder = VoiceRecorder( val recorder = VoiceRecorder(
scope = service.lifecycleScope, scope = service.lifecycleScope,
@@ -237,25 +262,37 @@ class AgentOverlay(private val service: LifecycleService) {
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
val dx = (event.rawX - initialTouchX).toInt() val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt() val dy = (event.rawY - initialTouchY).toInt()
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) isDragging = true if (!isDragging && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
pillParams.x = initialX + dx isDragging = true
pillParams.y = initialY + dy dismissTarget.show()
windowManager.updateViewLayout(view, pillParams) }
if (isDragging) {
pillParams.x = initialX + dx
pillParams.y = initialY + dy
windowManager.updateViewLayout(view, pillParams)
}
true true
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (!isDragging) { if (isDragging) {
if (mode.value == OverlayMode.Idle) { val dismissed = dismissTarget.isOverTarget(event.rawX, event.rawY)
startListening() dismissTarget.hide()
if (dismissed) {
// Reset position to default so next show() starts clean
pillParams.x = 0
pillParams.y = 200
hide()
}
} else {
// Tap: if running, stop goal; otherwise show command panel
if (ConnectionService.currentGoalStatus.value == GoalStatus.Running) {
ConnectionService.instance?.stopGoal()
} else { } else {
val intent = Intent(service, MainActivity::class.java).apply { hide()
flags = Intent.FLAG_ACTIVITY_NEW_TASK or commandPanel.show()
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
}
service.startActivity(intent)
} }
} }
isDragging = false
true true
} }
else -> false else -> false

View File

@@ -0,0 +1,349 @@
package com.thisux.droidclaw.overlay
import android.graphics.PixelFormat
import android.view.View
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.thisux.droidclaw.DroidClawApp
import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.ui.theme.DroidClawTheme
class CommandPanelOverlay(
private val service: LifecycleService,
private val onSubmitGoal: (String) -> Unit,
private val onStartVoice: () -> Unit,
private val onDismiss: () -> Unit
) {
private val windowManager = service.getSystemService(WindowManager::class.java)
private var composeView: ComposeView? = null
private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = service.lifecycle
override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
init { controller.performRestore(null) }
}
private val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
).apply {
softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE
}
fun show() {
if (composeView != null) return
val view = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
setViewTreeLifecycleOwner(service)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent {
CommandPanelContent(
onSubmitGoal = { goal ->
hide()
onSubmitGoal(goal)
onDismiss()
},
onStartVoice = {
hide()
onStartVoice()
},
onDismiss = {
hide()
onDismiss()
}
)
}
}
windowManager.addView(view, layoutParams)
composeView = view
}
fun hide() {
composeView?.let { windowManager.removeView(it) }
composeView = null
}
fun isShowing() = composeView != null
fun destroy() = hide()
}
private val DEFAULT_SUGGESTIONS = listOf(
"Open WhatsApp and reply to the last message",
"Take a screenshot and save it",
"Turn on Do Not Disturb",
"Search for nearby restaurants on Maps"
)
@Composable
private fun CommandPanelContent(
onSubmitGoal: (String) -> Unit,
onStartVoice: () -> Unit,
onDismiss: () -> Unit
) {
DroidClawTheme {
val context = LocalContext.current
val app = context.applicationContext as DroidClawApp
val recentGoals by app.settingsStore.recentGoals.collectAsState(initial = emptyList())
val connectionState by ConnectionService.connectionState.collectAsState()
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
val isConnected = connectionState == ConnectionState.Connected
val canSend = isConnected && goalStatus != GoalStatus.Running
var goalInput by remember { mutableStateOf("") }
// Auto-dismiss if a goal starts running
LaunchedEffect(goalStatus) {
if (goalStatus == GoalStatus.Running) {
onDismiss()
}
}
// Build suggestion list: recent goals first, fill remaining with defaults
val suggestions = remember(recentGoals) {
val combined = mutableListOf<String>()
combined.addAll(recentGoals.take(4))
for (default in DEFAULT_SUGGESTIONS) {
if (combined.size >= 4) break
if (default !in combined) combined.add(default)
}
combined.take(4)
}
Box(modifier = Modifier.fillMaxSize()) {
// Scrim - tap to dismiss
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onDismiss() }
)
// Bottom card
Surface(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.imePadding()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { /* consume clicks so they don't reach scrim */ },
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Handle bar
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
.align(Alignment.CenterHorizontally)
)
Text(
text = "What can I help with?",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
// 2x2 suggestion grid
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (row in suggestions.chunked(2)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
for (suggestion in row) {
SuggestionCard(
text = suggestion,
enabled = canSend,
onClick = { onSubmitGoal(suggestion) },
modifier = Modifier.weight(1f)
)
}
if (row.size < 2) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
// Text input
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val sendEnabled = canSend && goalInput.isNotBlank()
TextField(
value = goalInput,
onValueChange = { goalInput = it },
placeholder = {
Text(
if (!isConnected) "Not connected"
else "Enter a goal...",
style = MaterialTheme.typography.bodyMedium
)
},
modifier = Modifier.weight(1f),
enabled = canSend,
singleLine = true,
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
)
)
IconButton(
onClick = { onStartVoice() },
enabled = canSend,
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (canSend)
MaterialTheme.colorScheme.secondaryContainer
else Color.Transparent
)
) {
Icon(
Icons.Filled.Mic,
contentDescription = "Voice",
tint = if (canSend)
MaterialTheme.colorScheme.onSecondaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
IconButton(
onClick = {
if (goalInput.isNotBlank()) onSubmitGoal(goalInput)
},
enabled = sendEnabled,
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (sendEnabled)
MaterialTheme.colorScheme.primary
else Color.Transparent
)
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (sendEnabled)
MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
}
}
}
}
}
}
@Composable
private fun SuggestionCard(
text: String,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier.height(72.dp),
enabled = enabled,
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
contentAlignment = Alignment.CenterStart
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@@ -0,0 +1,121 @@
package com.thisux.droidclaw.overlay
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
class DismissTargetView(private val service: LifecycleService) {
private val windowManager = service.getSystemService(WindowManager::class.java)
private var composeView: ComposeView? = null
private val density = service.resources.displayMetrics.density
private var targetCenterX = 0f
private var targetCenterY = 0f
private var targetRadiusPx = 36f * density
private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = service.lifecycle
override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
init { controller.performRestore(null) }
}
private val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
}
fun show() {
if (composeView != null) return
// Compute target coordinates synchronously before showing the view
val metrics = windowManager.currentWindowMetrics
val screenWidth = metrics.bounds.width().toFloat()
val screenHeight = metrics.bounds.height().toFloat()
targetCenterX = screenWidth / 2f
// The circle is 56dp from bottom edge + 36dp (half of 72dp circle)
targetCenterY = screenHeight - (56f + 36f) * density
val view = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
setViewTreeLifecycleOwner(service)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent { DismissTargetContent() }
}
composeView = view
windowManager.addView(view, layoutParams)
}
fun hide() {
composeView?.let { windowManager.removeView(it) }
composeView = null
}
fun destroy() = hide()
fun isOverTarget(rawX: Float, rawY: Float): Boolean {
if (composeView == null) return false
val dx = rawX - targetCenterX
val dy = rawY - targetCenterY
// Use generous hit radius (1.5x visual radius) for easier targeting
val hitRadius = targetRadiusPx * 1.5f
return (dx * dx + dy * dy) <= (hitRadius * hitRadius)
}
}
@Composable
private fun DismissTargetContent() {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 56.dp),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(Color(0xCC333333)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Dismiss",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
}

View File

@@ -19,11 +19,11 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
private val GradientColors = listOf( private val GradientColors = listOf(
Color(0xFF8B5CF6), // purple Color(0xFFC62828), // crimson red
Color(0xFF3B82F6), // blue Color(0xFFEF5350), // crimson light
Color(0xFF06B6D4), // cyan Color(0xFFFFB300), // golden accent
Color(0xFF10B981), // green Color(0xFFEF5350), // crimson light
Color(0xFF8B5CF6), // purple (loop) Color(0xFFC62828), // crimson red (loop)
) )
@Composable @Composable

View File

@@ -2,28 +2,16 @@ package com.thisux.droidclaw.overlay
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -33,12 +21,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import com.thisux.droidclaw.R
import com.thisux.droidclaw.connection.ConnectionService import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.model.ConnectionState import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.GoalStatus import com.thisux.droidclaw.model.GoalStatus
@@ -49,16 +37,14 @@ private val Green = Color(0xFF4CAF50)
private val Blue = Color(0xFF2196F3) private val Blue = Color(0xFF2196F3)
private val Red = Color(0xFFF44336) private val Red = Color(0xFFF44336)
private val Gray = Color(0xFF9E9E9E) private val Gray = Color(0xFF9E9E9E)
private val PillBackground = Color(0xE6212121) private val IconBackground = Color(0xFF1A1A1A)
@Composable @Composable
fun OverlayContent() { fun OverlayContent() {
DroidClawTheme { DroidClawTheme {
val connectionState by ConnectionService.connectionState.collectAsState() val connectionState by ConnectionService.connectionState.collectAsState()
val goalStatus by ConnectionService.currentGoalStatus.collectAsState() val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
val steps by ConnectionService.currentSteps.collectAsState()
// Auto-reset Completed/Failed back to Idle after 3s
var displayStatus by remember { mutableStateOf(goalStatus) } var displayStatus by remember { mutableStateOf(goalStatus) }
LaunchedEffect(goalStatus) { LaunchedEffect(goalStatus) {
displayStatus = goalStatus displayStatus = goalStatus
@@ -70,102 +56,67 @@ fun OverlayContent() {
val isConnected = connectionState == ConnectionState.Connected val isConnected = connectionState == ConnectionState.Connected
val dotColor by animateColorAsState( val ringColor by animateColorAsState(
targetValue = when { targetValue = when {
!isConnected -> Gray !isConnected -> Gray
displayStatus == GoalStatus.Running -> Blue displayStatus == GoalStatus.Running -> Red
displayStatus == GoalStatus.Failed -> Red displayStatus == GoalStatus.Completed -> Blue
displayStatus == GoalStatus.Failed -> Gray
else -> Green else -> Green
}, },
label = "dotColor" label = "ringColor"
) )
val statusText = when { val isRunning = isConnected && displayStatus == GoalStatus.Running
!isConnected -> "Offline"
displayStatus == GoalStatus.Running -> {
val last = steps.lastOrNull()
if (last != null) {
val label = last.reasoning.ifBlank {
// Extract just the action name from the JSON string
Regex("""action[=:]?\s*(\w+)""").find(last.action)?.groupValues?.get(1) ?: "working"
}
"${last.step}: $label"
} else "Running..."
}
displayStatus == GoalStatus.Completed -> "Done"
displayStatus == GoalStatus.Failed -> "Stopped"
else -> "Ready"
}
Row( Box(
modifier = Modifier contentAlignment = Alignment.Center,
.clip(RoundedCornerShape(24.dp)) modifier = Modifier.size(52.dp)
.background(PillBackground)
.height(48.dp)
.widthIn(min = 100.dp, max = 220.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
StatusDot( // Background circle
color = dotColor, Box(
pulse = isConnected && displayStatus == GoalStatus.Running modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(IconBackground)
) )
Text( if (isRunning) {
text = statusText, // Spinning progress ring
color = Color.White, val transition = rememberInfiniteTransition(label = "spin")
fontSize = 13.sp, val rotation by transition.animateFloat(
maxLines = 1, initialValue = 0f,
overflow = TextOverflow.Ellipsis, targetValue = 360f,
modifier = Modifier.weight(1f, fill = false) animationSpec = infiniteRepeatable(
) animation = tween(1200, easing = LinearEasing)
),
if (isConnected && displayStatus == GoalStatus.Running) { label = "rotation"
IconButton( )
onClick = { ConnectionService.instance?.stopGoal() }, CircularProgressIndicator(
modifier = Modifier.size(28.dp), modifier = Modifier.size(52.dp),
colors = IconButtonDefaults.iconButtonColors( color = ringColor,
contentColor = Color.White.copy(alpha = 0.8f) strokeWidth = 3.dp,
) strokeCap = StrokeCap.Round
) { )
Icon( } else {
imageVector = Icons.Default.Close, // Static colored ring
contentDescription = "Stop goal", CircularProgressIndicator(
modifier = Modifier.size(16.dp) progress = { 1f },
) modifier = Modifier.size(52.dp),
} color = ringColor,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round
)
} }
// App icon
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = "DroidClaw",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
} }
} }
} }
@Composable
private fun StatusDot(color: Color, pulse: Boolean) {
if (pulse) {
val transition = rememberInfiniteTransition(label = "pulse")
val alpha by transition.animateFloat(
initialValue = 1f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulseAlpha"
)
Box(
modifier = Modifier
.size(10.dp)
.alpha(alpha)
.clip(CircleShape)
.background(color)
)
} else {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(color)
)
}
}

View File

@@ -0,0 +1,128 @@
package com.thisux.droidclaw.overlay
import android.graphics.PixelFormat
import android.view.View
import android.view.WindowManager
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
private val CrimsonGlow = Color(0xFFC62828)
class VignetteOverlay(private val service: LifecycleService) {
private val windowManager = service.getSystemService(WindowManager::class.java)
private var composeView: ComposeView? = null
private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = service.lifecycle
override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
init { controller.performRestore(null) }
}
private val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
)
fun show() {
if (composeView != null) return
val view = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
setViewTreeLifecycleOwner(service)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent { VignetteContent() }
}
composeView = view
windowManager.addView(view, layoutParams)
}
fun hide() {
composeView?.let { windowManager.removeView(it) }
composeView = null
}
fun destroy() = hide()
}
@Composable
private fun VignetteContent() {
val transition = rememberInfiniteTransition(label = "vignettePulse")
val alpha by transition.animateFloat(
initialValue = 0.5f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(2200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "vignetteAlpha"
)
Canvas(modifier = Modifier.fillMaxSize()) {
drawVignette(alpha)
}
}
private fun DrawScope.drawVignette(alpha: Float) {
val edgeColor = CrimsonGlow.copy(alpha = 0.4f * alpha)
val glowWidth = size.minDimension * 0.35f
// Top edge
drawRect(
brush = Brush.verticalGradient(
colors = listOf(edgeColor, Color.Transparent),
startY = 0f,
endY = glowWidth
)
)
// Bottom edge
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Transparent, edgeColor),
startY = size.height - glowWidth,
endY = size.height
)
)
// Left edge
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(edgeColor, Color.Transparent),
startX = 0f,
endX = glowWidth
)
)
// Right edge
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Transparent, edgeColor),
startX = size.width - glowWidth,
endX = size.width
)
)
}

View File

@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
private val AccentPurple = Color(0xFF8B5CF6) private val AccentCrimson = Color(0xFFC62828)
private val PanelBackground = Color(0xCC1A1A1A) private val PanelBackground = Color(0xCC1A1A1A)
@Composable @Composable
@@ -118,7 +118,7 @@ fun VoiceOverlayContent(
onClick = onSend, onClick = onSend,
enabled = transcript.isNotEmpty(), enabled = transcript.isNotEmpty(),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = AccentPurple, containerColor = AccentCrimson,
contentColor = Color.White contentColor = Color.White
), ),
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -153,6 +153,6 @@ private fun ListeningIndicator() {
.size(48.dp) .size(48.dp)
.alpha(alpha) .alpha(alpha)
.clip(CircleShape) .clip(CircleShape)
.background(AccentPurple) .background(AccentCrimson)
) )
} }

View File

@@ -1,10 +1,12 @@
package com.thisux.droidclaw.ui.screens package com.thisux.droidclaw.ui.screens
import android.app.Activity import android.app.Activity
import android.app.role.RoleManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -96,6 +98,14 @@ fun SettingsScreen() {
var hasOverlayPermission by remember { var hasOverlayPermission by remember {
mutableStateOf(Settings.canDrawOverlays(context)) mutableStateOf(Settings.canDrawOverlays(context))
} }
var isDefaultAssistant by remember {
mutableStateOf(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rm = context.getSystemService(Context.ROLE_SERVICE) as RoleManager
rm.isRoleHeld(RoleManager.ROLE_ASSISTANT)
} else false
)
}
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
@@ -106,6 +116,10 @@ fun SettingsScreen() {
hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent() hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent()
isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(context) isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(context)
hasOverlayPermission = Settings.canDrawOverlays(context) hasOverlayPermission = Settings.canDrawOverlays(context)
isDefaultAssistant = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rm = context.getSystemService(Context.ROLE_SERVICE) as RoleManager
rm.isRoleHeld(RoleManager.ROLE_ASSISTANT)
} else false
} }
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
@@ -300,6 +314,17 @@ fun SettingsScreen() {
} }
) )
ChecklistItem(
label = "Default digital assistant",
isOk = isDefaultAssistant,
actionLabel = "Set",
onAction = {
context.startActivity(
Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
)
}
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }

View File

@@ -0,0 +1,5 @@
package com.thisux.droidclaw.voice
import android.service.voice.VoiceInteractionService
class DroidClawVoiceInteractionService : VoiceInteractionService()

View File

@@ -0,0 +1,76 @@
package com.thisux.droidclaw.voice
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.view.View
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.ui.theme.DroidClawTheme
class DroidClawVoiceSession(context: Context) : VoiceInteractionSession(context) {
private val lifecycleOwner = object : LifecycleOwner {
val registry = LifecycleRegistry(this)
override val lifecycle: Lifecycle get() = registry
}
private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = lifecycleOwner.lifecycle
override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
init { controller.performRestore(null) }
}
override fun onCreateContentView(): View {
lifecycleOwner.registry.currentState = Lifecycle.State.CREATED
return ComposeView(context).apply {
setViewTreeLifecycleOwner(lifecycleOwner)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent {
DroidClawTheme {
GoalInputSheet(
onSubmit = { goal -> submitGoal(goal) },
onDismiss = { hide() }
)
}
}
}
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
lifecycleOwner.registry.currentState = Lifecycle.State.STARTED
lifecycleOwner.registry.currentState = Lifecycle.State.RESUMED
}
override fun onHide() {
lifecycleOwner.registry.currentState = Lifecycle.State.STARTED
lifecycleOwner.registry.currentState = Lifecycle.State.CREATED
super.onHide()
}
override fun onDestroy() {
lifecycleOwner.registry.currentState = Lifecycle.State.DESTROYED
super.onDestroy()
}
private fun submitGoal(goal: String) {
val intent = Intent(context, ConnectionService::class.java).apply {
action = ConnectionService.ACTION_SEND_GOAL
putExtra(ConnectionService.EXTRA_GOAL, goal)
}
context.startService(intent)
hide()
}
}

View File

@@ -0,0 +1,11 @@
package com.thisux.droidclaw.voice
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.service.voice.VoiceInteractionSessionService
class DroidClawVoiceSessionService : VoiceInteractionSessionService() {
override fun onNewSession(args: Bundle?): VoiceInteractionSession {
return DroidClawVoiceSession(this)
}
}

View File

@@ -0,0 +1,158 @@
package com.thisux.droidclaw.voice
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.GoalStatus
@Composable
fun GoalInputSheet(
onSubmit: (String) -> Unit,
onDismiss: () -> Unit
) {
val connectionState by ConnectionService.connectionState.collectAsState()
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
val isConnected = connectionState == ConnectionState.Connected
val isRunning = goalStatus == GoalStatus.Running
val canSend = isConnected && !isRunning
var text by remember { mutableStateOf("") }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onDismiss() }
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { /* consume clicks so they don't dismiss */ },
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
tonalElevation = 6.dp,
color = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 12.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Drag handle
Box(
modifier = Modifier
.width(40.dp)
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
.align(Alignment.CenterHorizontally)
)
Text(
text = "What should I do?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = text,
onValueChange = { text = it },
placeholder = {
Text(
when {
!isConnected -> "Not connected"
isRunning -> "Agent is working..."
else -> "Enter a goal..."
},
style = MaterialTheme.typography.bodyMedium
)
},
modifier = Modifier.weight(1f),
enabled = canSend,
singleLine = true,
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.3f),
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.2f),
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
.copy(alpha = 0.1f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent
)
)
IconButton(
onClick = {
if (text.isNotBlank()) onSubmit(text.trim())
},
enabled = canSend && text.isNotBlank(),
colors = IconButtonDefaults.iconButtonColors(
containerColor = if (canSend && text.isNotBlank())
MaterialTheme.colorScheme.primary
else Color.Transparent
)
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send goal",
tint = if (canSend && text.isNotBlank())
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService="com.thisux.droidclaw.voice.DroidClawVoiceSessionService"
android:settingsActivity="com.thisux.droidclaw.MainActivity"
android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="false" />