From 474395e8c4026dc989a56e01829bc8f6f458db64 Mon Sep 17 00:00:00 2001 From: Somasundaram Mahesh Date: Fri, 20 Feb 2026 06:23:00 +0530 Subject: [PATCH] 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 --- android/app/src/main/AndroidManifest.xml | 24 +- .../java/com/thisux/droidclaw/MainActivity.kt | 10 + .../droidclaw/connection/CommandRouter.kt | 11 +- .../droidclaw/connection/ConnectionService.kt | 27 +- .../thisux/droidclaw/data/SettingsStore.kt | 23 ++ .../thisux/droidclaw/overlay/AgentOverlay.kt | 63 +++- .../droidclaw/overlay/CommandPanelOverlay.kt | 349 ++++++++++++++++++ .../droidclaw/overlay/DismissTargetView.kt | 121 ++++++ .../droidclaw/overlay/GradientBorder.kt | 10 +- .../droidclaw/overlay/OverlayContent.kt | 161 +++----- .../droidclaw/overlay/VignetteOverlay.kt | 128 +++++++ .../droidclaw/overlay/VoiceOverlayContent.kt | 6 +- .../droidclaw/ui/screens/SettingsScreen.kt | 25 ++ .../voice/DroidClawVoiceInteractionService.kt | 5 + .../droidclaw/voice/DroidClawVoiceSession.kt | 76 ++++ .../voice/DroidClawVoiceSessionService.kt | 11 + .../thisux/droidclaw/voice/GoalInputSheet.kt | 158 ++++++++ .../res/xml/voice_interaction_service.xml | 6 + 18 files changed, 1085 insertions(+), 129 deletions(-) create mode 100644 android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt create mode 100644 android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt create mode 100644 android/app/src/main/res/xml/voice_interaction_service.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fde33c7..cd6001f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,13 +28,17 @@ - + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt b/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt index f47cfc9..a661562 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/MainActivity.kt @@ -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) diff --git a/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt b/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt index cce74e7..1bea837 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/connection/CommandRouter.kt @@ -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(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) } diff --git a/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt b/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt index e1d9fa6..22cefae 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/connection/ConnectionService.kt @@ -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 diff --git a/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt b/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt index d141a10..c8c435e 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/data/SettingsStore.kt @@ -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 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> = 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() + } + } } diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt index d750e42..68bf974 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/AgentOverlay.kt @@ -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 diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt new file mode 100644 index 0000000..8538049 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/CommandPanelOverlay.kt @@ -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() + 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 + ) + } + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt new file mode 100644 index 0000000..3876e9e --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/DismissTargetView.kt @@ -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) + ) + } + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt index 316e3f9..9bccb97 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/GradientBorder.kt @@ -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 diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt index a793ba7..89f2df4 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/OverlayContent.kt @@ -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) - ) - } -} diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt new file mode 100644 index 0000000..b3703ce --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/VignetteOverlay.kt @@ -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 + ) + ) +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt b/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt index b3a68e1..890158f 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/overlay/VoiceOverlayContent.kt @@ -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) ) } diff --git a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt index 07ffbca..ef6250c 100644 --- a/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt +++ b/android/app/src/main/java/com/thisux/droidclaw/ui/screens/SettingsScreen.kt @@ -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)) } } diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt new file mode 100644 index 0000000..e10e840 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceInteractionService.kt @@ -0,0 +1,5 @@ +package com.thisux.droidclaw.voice + +import android.service.voice.VoiceInteractionService + +class DroidClawVoiceInteractionService : VoiceInteractionService() diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt new file mode 100644 index 0000000..a7e3d64 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSession.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt new file mode 100644 index 0000000..da1753c --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/voice/DroidClawVoiceSessionService.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt b/android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt new file mode 100644 index 0000000..b9bd6f6 --- /dev/null +++ b/android/app/src/main/java/com/thisux/droidclaw/voice/GoalInputSheet.kt @@ -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) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/res/xml/voice_interaction_service.xml b/android/app/src/main/res/xml/voice_interaction_service.xml new file mode 100644 index 0000000..ef25a6f --- /dev/null +++ b/android/app/src/main/res/xml/voice_interaction_service.xml @@ -0,0 +1,6 @@ + +