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:
@@ -28,13 +28,17 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.DroidClaw">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.ASSIST" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
@@ -54,6 +58,24 @@
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
@@ -3,10 +3,12 @@ package com.thisux.droidclaw
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.thisux.droidclaw.connection.ConnectionService
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -75,6 +77,14 @@ class MainActivity : ComponentActivity() {
|
||||
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)
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.thisux.droidclaw.model.PongMessage
|
||||
import com.thisux.droidclaw.model.ResultResponse
|
||||
import com.thisux.droidclaw.model.ScreenResponse
|
||||
import com.thisux.droidclaw.model.ServerMessage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class CommandRouter(
|
||||
@@ -27,6 +28,10 @@ class CommandRouter(
|
||||
val currentGoal = MutableStateFlow("")
|
||||
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
|
||||
|
||||
fun updateGestureExecutor() {
|
||||
@@ -87,7 +92,7 @@ class CommandRouter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetScreen(requestId: String) {
|
||||
private suspend fun handleGetScreen(requestId: String) {
|
||||
updateGestureExecutor()
|
||||
val svc = DroidClawAccessibilityService.instance
|
||||
val elements = svc?.getScreenTree() ?: emptyList()
|
||||
@@ -97,7 +102,11 @@ class CommandRouter(
|
||||
|
||||
var screenshot: String? = null
|
||||
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()
|
||||
afterScreenCapture?.invoke()
|
||||
if (bytes != null) {
|
||||
screenshot = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,13 @@ class ConnectionService : LifecycleService() {
|
||||
webSocket = ws
|
||||
|
||||
val router = CommandRouter(ws, captureManager)
|
||||
router.beforeScreenCapture = { overlay?.hideVignette() }
|
||||
router.afterScreenCapture = {
|
||||
if (currentGoalStatus.value == GoalStatus.Running &&
|
||||
Settings.canDrawOverlays(this@ConnectionService)) {
|
||||
overlay?.showVignette()
|
||||
}
|
||||
}
|
||||
commandRouter = router
|
||||
|
||||
launch {
|
||||
@@ -173,7 +180,24 @@ class ConnectionService : LifecycleService() {
|
||||
}
|
||||
launch { ws.errorMessage.collect { errorMessage.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 } }
|
||||
|
||||
acquireWakeLock()
|
||||
@@ -206,6 +230,7 @@ class ConnectionService : LifecycleService() {
|
||||
}
|
||||
|
||||
private fun disconnect() {
|
||||
overlay?.hideVignette()
|
||||
overlay?.hide()
|
||||
webSocket?.disconnect()
|
||||
webSocket = null
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||
|
||||
@@ -18,6 +19,7 @@ object SettingsKeys {
|
||||
val DEVICE_NAME = stringPreferencesKey("device_name")
|
||||
val AUTO_CONNECT = booleanPreferencesKey("auto_connect")
|
||||
val HAS_ONBOARDED = booleanPreferencesKey("has_onboarded")
|
||||
val RECENT_GOALS = stringPreferencesKey("recent_goals")
|
||||
}
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
@@ -61,4 +63,25 @@ class SettingsStore(private val context: Context) {
|
||||
suspend fun setHasOnboarded(value: Boolean) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,16 @@ import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
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.ui.theme.DroidClawTheme
|
||||
|
||||
class AgentOverlay(private val service: LifecycleService) {
|
||||
|
||||
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 controller = SavedStateRegistryController.create(this)
|
||||
@@ -50,6 +54,20 @@ class AgentOverlay(private val service: LifecycleService) {
|
||||
// ── Voice recorder ──────────────────────────────────────
|
||||
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 ───────────────────────────────────────
|
||||
|
||||
private val pillParams = WindowManager.LayoutParams(
|
||||
@@ -93,14 +111,21 @@ class AgentOverlay(private val service: LifecycleService) {
|
||||
fun hide() {
|
||||
hidePill()
|
||||
hideVoiceOverlay()
|
||||
dismissTarget.hide()
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
hide()
|
||||
commandPanel.destroy()
|
||||
vignetteOverlay.destroy()
|
||||
voiceRecorder?.stop()
|
||||
voiceRecorder = null
|
||||
}
|
||||
|
||||
fun showVignette() = vignetteOverlay.show()
|
||||
|
||||
fun hideVignette() = vignetteOverlay.hide()
|
||||
|
||||
fun startListening() {
|
||||
val recorder = VoiceRecorder(
|
||||
scope = service.lifecycleScope,
|
||||
@@ -237,25 +262,37 @@ class AgentOverlay(private val service: LifecycleService) {
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val dx = (event.rawX - initialTouchX).toInt()
|
||||
val dy = (event.rawY - initialTouchY).toInt()
|
||||
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) isDragging = true
|
||||
pillParams.x = initialX + dx
|
||||
pillParams.y = initialY + dy
|
||||
windowManager.updateViewLayout(view, pillParams)
|
||||
if (!isDragging && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
|
||||
isDragging = true
|
||||
dismissTarget.show()
|
||||
}
|
||||
if (isDragging) {
|
||||
pillParams.x = initialX + dx
|
||||
pillParams.y = initialY + dy
|
||||
windowManager.updateViewLayout(view, pillParams)
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
if (mode.value == OverlayMode.Idle) {
|
||||
startListening()
|
||||
if (isDragging) {
|
||||
val dismissed = dismissTarget.isOverTarget(event.rawX, event.rawY)
|
||||
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 {
|
||||
val intent = Intent(service, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||
}
|
||||
service.startActivity(intent)
|
||||
hide()
|
||||
commandPanel.show()
|
||||
}
|
||||
}
|
||||
isDragging = false
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val GradientColors = listOf(
|
||||
Color(0xFF8B5CF6), // purple
|
||||
Color(0xFF3B82F6), // blue
|
||||
Color(0xFF06B6D4), // cyan
|
||||
Color(0xFF10B981), // green
|
||||
Color(0xFF8B5CF6), // purple (loop)
|
||||
Color(0xFFC62828), // crimson red
|
||||
Color(0xFFEF5350), // crimson light
|
||||
Color(0xFFFFB300), // golden accent
|
||||
Color(0xFFEF5350), // crimson light
|
||||
Color(0xFFC62828), // crimson red (loop)
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -2,28 +2,16 @@ package com.thisux.droidclaw.overlay
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
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.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -33,12 +21,12 @@ 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.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.sp
|
||||
import com.thisux.droidclaw.R
|
||||
import com.thisux.droidclaw.connection.ConnectionService
|
||||
import com.thisux.droidclaw.model.ConnectionState
|
||||
import com.thisux.droidclaw.model.GoalStatus
|
||||
@@ -49,16 +37,14 @@ private val Green = Color(0xFF4CAF50)
|
||||
private val Blue = Color(0xFF2196F3)
|
||||
private val Red = Color(0xFFF44336)
|
||||
private val Gray = Color(0xFF9E9E9E)
|
||||
private val PillBackground = Color(0xE6212121)
|
||||
private val IconBackground = Color(0xFF1A1A1A)
|
||||
|
||||
@Composable
|
||||
fun OverlayContent() {
|
||||
DroidClawTheme {
|
||||
val connectionState by ConnectionService.connectionState.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) }
|
||||
LaunchedEffect(goalStatus) {
|
||||
displayStatus = goalStatus
|
||||
@@ -70,102 +56,67 @@ fun OverlayContent() {
|
||||
|
||||
val isConnected = connectionState == ConnectionState.Connected
|
||||
|
||||
val dotColor by animateColorAsState(
|
||||
val ringColor by animateColorAsState(
|
||||
targetValue = when {
|
||||
!isConnected -> Gray
|
||||
displayStatus == GoalStatus.Running -> Blue
|
||||
displayStatus == GoalStatus.Failed -> Red
|
||||
displayStatus == GoalStatus.Running -> Red
|
||||
displayStatus == GoalStatus.Completed -> Blue
|
||||
displayStatus == GoalStatus.Failed -> Gray
|
||||
else -> Green
|
||||
},
|
||||
label = "dotColor"
|
||||
label = "ringColor"
|
||||
)
|
||||
|
||||
val statusText = when {
|
||||
!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"
|
||||
}
|
||||
val isRunning = isConnected && displayStatus == GoalStatus.Running
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.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)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
StatusDot(
|
||||
color = dotColor,
|
||||
pulse = isConnected && displayStatus == GoalStatus.Running
|
||||
// Background circle
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(52.dp)
|
||||
.clip(CircleShape)
|
||||
.background(IconBackground)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = statusText,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
)
|
||||
|
||||
if (isConnected && displayStatus == GoalStatus.Running) {
|
||||
IconButton(
|
||||
onClick = { ConnectionService.instance?.stopGoal() },
|
||||
modifier = Modifier.size(28.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Stop goal",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
if (isRunning) {
|
||||
// Spinning progress ring
|
||||
val transition = rememberInfiniteTransition(label = "spin")
|
||||
val rotation by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing)
|
||||
),
|
||||
label = "rotation"
|
||||
)
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(52.dp),
|
||||
color = ringColor,
|
||||
strokeWidth = 3.dp,
|
||||
strokeCap = StrokeCap.Round
|
||||
)
|
||||
} else {
|
||||
// Static colored ring
|
||||
CircularProgressIndicator(
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val AccentPurple = Color(0xFF8B5CF6)
|
||||
private val AccentCrimson = Color(0xFFC62828)
|
||||
private val PanelBackground = Color(0xCC1A1A1A)
|
||||
|
||||
@Composable
|
||||
@@ -118,7 +118,7 @@ fun VoiceOverlayContent(
|
||||
onClick = onSend,
|
||||
enabled = transcript.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = AccentPurple,
|
||||
containerColor = AccentCrimson,
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
@@ -153,6 +153,6 @@ private fun ListeningIndicator() {
|
||||
.size(48.dp)
|
||||
.alpha(alpha)
|
||||
.clip(CircleShape)
|
||||
.background(AccentPurple)
|
||||
.background(AccentCrimson)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.thisux.droidclaw.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.role.RoleManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -96,6 +98,14 @@ fun SettingsScreen() {
|
||||
var hasOverlayPermission by remember {
|
||||
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
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
@@ -106,6 +116,10 @@ fun SettingsScreen() {
|
||||
hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent()
|
||||
isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(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)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.thisux.droidclaw.voice
|
||||
|
||||
import android.service.voice.VoiceInteractionService
|
||||
|
||||
class DroidClawVoiceInteractionService : VoiceInteractionService()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
Reference in New Issue
Block a user