DEV Community

StareBrain
StareBrain

Posted on

Building StareBrain, Part 18: Auto-Collapse — The Overlay That Knows When to Hide

Building StareBrain, Part 18: Auto-Collapse — The Overlay That Knows When to Hide

Part 17 covered drag-to-reposition. Today we tackle the other half of staying out of the way: auto-collapse.

The overlay has three states — Idle, Running, AwaitingConfirmation. What we hadn't solved was when to transition between them based on what the user is doing, not just what the task is doing. If a step completes while the user is typing in another app, expanding the overlay mid-keystroke is the wrong call.

Auto-collapse means the overlay shrinks to a dot when the user is actively interacting with another app, and expands only when something actually needs their attention.


The Signal: AccessibilityEvent

We already have an AccessibilityService running. It fires events on every interaction — focus changes, window changes, text input. We use that stream to detect active interaction.

class StareBrainService : AccessibilityService() {

    private val interactionTracker = UserInteractionTracker()

    override fun onAccessibilityEvent(event: AccessibilityEvent) {
        when (event.eventType) {
            AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
            AccessibilityEvent.TYPE_VIEW_CLICKED,
            AccessibilityEvent.TYPE_VIEW_FOCUSED -> {
                interactionTracker.recordInteraction()
            }
            AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
                interactionTracker.recordAppSwitch(event.packageName?.toString())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

UserInteractionTracker

Tracks the last interaction timestamp and whether the user is considered "active":

class UserInteractionTracker {

    private var lastInteractionAt: Long = 0L
    private var lastPackage: String? = null

    fun recordInteraction() {
        lastInteractionAt = System.currentTimeMillis()
    }

    fun recordAppSwitch(packageName: String?) {
        lastPackage = packageName
        lastInteractionAt = System.currentTimeMillis()
    }

    fun isUserActive(thresholdMs: Long = 3_000): Boolean {
        return System.currentTimeMillis() - lastInteractionAt < thresholdMs
    }

    fun currentPackage(): String? = lastPackage
}
Enter fullscreen mode Exit fullscreen mode

3 seconds of no interaction → not active. This is intentionally short — we want the overlay to re-expand quickly when a step completes, not wait for a long idle period.


CollapseController

Decides whether the overlay should be collapsed based on task state and user activity:

class CollapseController(
    private val interactionTracker: UserInteractionTracker
) {

    sealed class CollapseDecision {
        object Collapse : CollapseDecision()
        object Expand : CollapseDecision()
        object ExpandUrgent : CollapseDecision() // confirmation required
    }

    fun evaluate(taskState: TaskStatus?): CollapseDecision {
        return when (taskState) {
            TaskStatus.AWAITING_CONFIRMATION -> CollapseDecision.ExpandUrgent
            TaskStatus.IN_PROGRESS -> {
                if (interactionTracker.isUserActive()) CollapseDecision.Collapse
                else CollapseDecision.Expand
            }
            else -> CollapseDecision.Collapse
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

ExpandUrgent bypasses the activity check — a confirmation always surfaces, even mid-keystroke. Everything else respects whether the user is currently active.


Applying the Decision

We evaluate CollapseDecision on a ticker — every 500ms — rather than reacting to every individual event:

private fun startCollapseLoop() {
    lifecycleScope.launch {
        while (isActive) {
            val task = taskExecutor.taskState.value
            val decision = collapseController.evaluate(task?.status)
            when (decision) {
                CollapseDecision.Collapse -> overlayManager.collapse()
                CollapseDecision.Expand -> overlayManager.expand()
                CollapseDecision.ExpandUrgent -> overlayManager.expandUrgent()
            }
            delay(500)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

500ms polling avoids the jitter of reacting to every AccessibilityEvent while still feeling responsive.


Collapse Animation

The overlay animates between collapsed (dot) and expanded states:

fun collapse() {
    if (currentState == OverlayDisplayState.COLLAPSED) return
    currentState = OverlayDisplayState.COLLAPSED
    animate()
        .scaleX(0.15f)
        .scaleY(0.15f)
        .alpha(0.4f)
        .setDuration(200)
        .start()
}

fun expand() {
    if (currentState == OverlayDisplayState.EXPANDED) return
    currentState = OverlayDisplayState.EXPANDED
    animate()
        .scaleX(1f)
        .scaleY(1f)
        .alpha(1f)
        .setDuration(200)
        .start()
}

fun expandUrgent() {
    expand()
    // Brief pulse to draw attention without being aggressive
    postDelayed({
        animate().scaleX(1.05f).scaleY(1.05f).setDuration(80).withEndAction {
            animate().scaleX(1f).scaleY(1f).setDuration(80).start()
        }.start()
    }, 200)
}
Enter fullscreen mode Exit fullscreen mode

The guard check (if (currentState == ...) return) prevents redundant animations on every 500ms tick.


What's Next

Part 19 will cover the voice input layer — how StareBrain accepts spoken instructions while another app is in the foreground, without requiring the user to switch back to StareBrain first.

Top comments (0)