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
|
<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>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
isDragging = true
|
||||||
|
dismissTarget.show()
|
||||||
|
}
|
||||||
|
if (isDragging) {
|
||||||
pillParams.x = initialX + dx
|
pillParams.x = initialX + dx
|
||||||
pillParams.y = initialY + dy
|
pillParams.y = initialY + dy
|
||||||
windowManager.updateViewLayout(view, pillParams)
|
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 {
|
} else {
|
||||||
val intent = Intent(service, MainActivity::class.java).apply {
|
// Tap: if running, stop goal; otherwise show command panel
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
if (ConnectionService.currentGoalStatus.value == GoalStatus.Running) {
|
||||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
ConnectionService.instance?.stopGoal()
|
||||||
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
} else {
|
||||||
}
|
hide()
|
||||||
service.startActivity(intent)
|
commandPanel.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDragging = false
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
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
|
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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
pulse = isConnected && displayStatus == GoalStatus.Running
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(10.dp)
|
.size(52.dp)
|
||||||
.alpha(alpha)
|
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(color)
|
.background(IconBackground)
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
Box(
|
// Static colored ring
|
||||||
modifier = Modifier
|
CircularProgressIndicator(
|
||||||
.size(10.dp)
|
progress = { 1f },
|
||||||
.clip(CircleShape)
|
modifier = Modifier.size(52.dp),
|
||||||
.background(color)
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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