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:
Kohei 2026-02-22 20:27:57 +09:00
parent 2c5d8fbf5d
commit a3a37c0c67
2 changed files with 36 additions and 5 deletions

View file

@ -87,6 +87,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
private lateinit var screenCaptureManager: ScreenCaptureManager
private var showAccessibilityGuide by mutableStateOf(false)
private var overlayAtTop by mutableStateOf(false)
private var overlayView: View? = null
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
@ -224,6 +225,14 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
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) {
val view = overlayView ?: return
val lp = view.layoutParams as? WindowManager.LayoutParams ?: return
@ -252,15 +261,16 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM
gravity = if (overlayAtTop) Gravity.TOP else Gravity.BOTTOM
}
val wrapper = object : FrameLayout(this@AssistantService) {
@Volatile var contentTop = fixedHeightPx
@Volatile var contentBottom = Int.MAX_VALUE
private var gestureInContent = false
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
gestureInContent = ev.y >= contentTop
gestureInContent = ev.y >= contentTop && ev.y <= contentBottom
}
if (!gestureInContent) return false
return super.dispatchTouchEvent(ev)
@ -276,17 +286,20 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
val state by assistantManager.state.collectAsState()
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
contentAlignment = if (overlayAtTop) Alignment.TopCenter else Alignment.BottomCenter
) {
AssistantPillBar(
state = state,
isAtTop = overlayAtTop,
onClose = { shutdown() },
onInterrupt = { assistantManager.interrupt() },
onPositionChange = { top -> moveOverlayTo(top) },
onCameraToggle = { handleCameraToggle() },
onScreenCaptureToggle = { handleScreenCaptureToggle() },
cameraCaptureManager = cameraCaptureManager,
modifier = Modifier.onGloballyPositioned { coordinates ->
wrapper.contentTop = coordinates.positionInWindow().y.toInt()
wrapper.contentBottom = (coordinates.positionInWindow().y + coordinates.size.height).toInt()
}
)
}

View file

@ -73,8 +73,10 @@ private val ErrorColor = Color(0xFFEF4444)
@Composable
fun AssistantPillBar(
state: VoiceModeState,
isAtTop: Boolean,
onClose: () -> Unit,
onInterrupt: () -> Unit,
onPositionChange: (Boolean) -> Unit,
onCameraToggle: () -> Unit,
onScreenCaptureToggle: () -> Unit,
cameraCaptureManager: CameraCaptureManager,
@ -97,8 +99,8 @@ fun AssistantPillBar(
// Camera preview above the pill bar
AnimatedVisibility(
visible = state.isCameraActive,
enter = expandVertically(expandFrom = Alignment.Bottom),
exit = shrinkVertically(shrinkTowards = Alignment.Bottom)
enter = expandVertically(expandFrom = if (isAtTop) Alignment.Top else Alignment.Bottom),
exit = shrinkVertically(shrinkTowards = if (isAtTop) Alignment.Top else Alignment.Bottom)
) {
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
IconButton(
onClick = onClose,