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 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()
} }
) )
} }

View file

@ -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,