feat: add overlay top/bottom snap toggle button
Allow users to move the assistant pill bar between top and bottom of the screen via a chevron button, preventing it from blocking chat input fields or other UI elements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2c5d8fbf5d
commit
a3a37c0c67
2 changed files with 36 additions and 5 deletions
|
|
@ -87,6 +87,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
||||||
private lateinit var screenCaptureManager: ScreenCaptureManager
|
private lateinit var screenCaptureManager: ScreenCaptureManager
|
||||||
|
|
||||||
private var showAccessibilityGuide by mutableStateOf(false)
|
private var showAccessibilityGuide by mutableStateOf(false)
|
||||||
|
private var overlayAtTop by mutableStateOf(false)
|
||||||
private var overlayView: View? = null
|
private var overlayView: View? = null
|
||||||
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
|
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
|
||||||
|
|
||||||
|
|
@ -224,6 +225,14 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun moveOverlayTo(top: Boolean) {
|
||||||
|
val view = overlayView ?: return
|
||||||
|
val lp = view.layoutParams as? WindowManager.LayoutParams ?: return
|
||||||
|
lp.gravity = if (top) Gravity.TOP else Gravity.BOTTOM
|
||||||
|
windowManager.updateViewLayout(view, lp)
|
||||||
|
overlayAtTop = top
|
||||||
|
}
|
||||||
|
|
||||||
private fun setOverlayVisible(visible: Boolean) {
|
private fun setOverlayVisible(visible: Boolean) {
|
||||||
val view = overlayView ?: return
|
val view = overlayView ?: return
|
||||||
val lp = view.layoutParams as? WindowManager.LayoutParams ?: return
|
val lp = view.layoutParams as? WindowManager.LayoutParams ?: return
|
||||||
|
|
@ -252,15 +261,16 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
||||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||||
PixelFormat.TRANSLUCENT
|
PixelFormat.TRANSLUCENT
|
||||||
).apply {
|
).apply {
|
||||||
gravity = Gravity.BOTTOM
|
gravity = if (overlayAtTop) Gravity.TOP else Gravity.BOTTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
val wrapper = object : FrameLayout(this@AssistantService) {
|
val wrapper = object : FrameLayout(this@AssistantService) {
|
||||||
@Volatile var contentTop = fixedHeightPx
|
@Volatile var contentTop = fixedHeightPx
|
||||||
|
@Volatile var contentBottom = Int.MAX_VALUE
|
||||||
private var gestureInContent = false
|
private var gestureInContent = false
|
||||||
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
|
||||||
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
|
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||||
gestureInContent = ev.y >= contentTop
|
gestureInContent = ev.y >= contentTop && ev.y <= contentBottom
|
||||||
}
|
}
|
||||||
if (!gestureInContent) return false
|
if (!gestureInContent) return false
|
||||||
return super.dispatchTouchEvent(ev)
|
return super.dispatchTouchEvent(ev)
|
||||||
|
|
@ -276,17 +286,20 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
|
||||||
val state by assistantManager.state.collectAsState()
|
val state by assistantManager.state.collectAsState()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.BottomCenter
|
contentAlignment = if (overlayAtTop) Alignment.TopCenter else Alignment.BottomCenter
|
||||||
) {
|
) {
|
||||||
AssistantPillBar(
|
AssistantPillBar(
|
||||||
state = state,
|
state = state,
|
||||||
|
isAtTop = overlayAtTop,
|
||||||
onClose = { shutdown() },
|
onClose = { shutdown() },
|
||||||
onInterrupt = { assistantManager.interrupt() },
|
onInterrupt = { assistantManager.interrupt() },
|
||||||
|
onPositionChange = { top -> moveOverlayTo(top) },
|
||||||
onCameraToggle = { handleCameraToggle() },
|
onCameraToggle = { handleCameraToggle() },
|
||||||
onScreenCaptureToggle = { handleScreenCaptureToggle() },
|
onScreenCaptureToggle = { handleScreenCaptureToggle() },
|
||||||
cameraCaptureManager = cameraCaptureManager,
|
cameraCaptureManager = cameraCaptureManager,
|
||||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||||
wrapper.contentTop = coordinates.positionInWindow().y.toInt()
|
wrapper.contentTop = coordinates.positionInWindow().y.toInt()
|
||||||
|
wrapper.contentBottom = (coordinates.positionInWindow().y + coordinates.size.height).toInt()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,10 @@ private val ErrorColor = Color(0xFFEF4444)
|
||||||
@Composable
|
@Composable
|
||||||
fun AssistantPillBar(
|
fun AssistantPillBar(
|
||||||
state: VoiceModeState,
|
state: VoiceModeState,
|
||||||
|
isAtTop: Boolean,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onInterrupt: () -> Unit,
|
onInterrupt: () -> Unit,
|
||||||
|
onPositionChange: (Boolean) -> Unit,
|
||||||
onCameraToggle: () -> Unit,
|
onCameraToggle: () -> Unit,
|
||||||
onScreenCaptureToggle: () -> Unit,
|
onScreenCaptureToggle: () -> Unit,
|
||||||
cameraCaptureManager: CameraCaptureManager,
|
cameraCaptureManager: CameraCaptureManager,
|
||||||
|
|
@ -97,8 +99,8 @@ fun AssistantPillBar(
|
||||||
// Camera preview above the pill bar
|
// Camera preview above the pill bar
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isCameraActive,
|
visible = state.isCameraActive,
|
||||||
enter = expandVertically(expandFrom = Alignment.Bottom),
|
enter = expandVertically(expandFrom = if (isAtTop) Alignment.Top else Alignment.Bottom),
|
||||||
exit = shrinkVertically(shrinkTowards = Alignment.Bottom)
|
exit = shrinkVertically(shrinkTowards = if (isAtTop) Alignment.Top else Alignment.Bottom)
|
||||||
) {
|
) {
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
|
@ -286,6 +288,22 @@ fun AssistantPillBar(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move position toggle
|
||||||
|
IconButton(
|
||||||
|
onClick = { onPositionChange(!isAtTop) },
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (isAtTop) LucideR.drawable.lucide_ic_chevron_down
|
||||||
|
else LucideR.drawable.lucide_ic_chevron_up
|
||||||
|
),
|
||||||
|
contentDescription = if (isAtTop) "Move to bottom" else "Move to top",
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Close button
|
// Close button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onClose,
|
onClick = onClose,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue