Slide the main screen over a session with a right swipe
Switching conversation was a step back to the list and a step down into another, which disposed the session being left and refetched its whole transcript over the tunnel on the way back. A right swipe now pulls MainScreen itself over the open session -- the screen Back would have shown, moved over the session instead of replacing it -- and swiping it back off returns to a live stream, an unsent draft and the scroll position it had. Tapping the session already open is that same swipe back; tapping another is a screen of its own; deleting the one underneath closes the screen, since there is nothing left to return to. One gesture drives both this and the subagent panel (SidePanels.kt, now the home of the drag and animation SubagentPanel had): two draggables over the same content cannot share a horizontal drag, so the position is a single signed reveal, negative left and positive right, which also makes it impossible to have both open. The panels exist only inside a session, so nothing on the main screen swipes anywhere. Full width and no tonal step for this one, because a screen standing in for another must be the same colour as it; the subagent panel keeps its 88% and its sliver. The list keeps its rows while it asks again -- the panel refetches on every open, and blanking it each time handed the reader an empty screen about something never in doubt -- with a bar over the top while an answer is outstanding. Where the panel has got to is read from draw lambdas only: it changes every frame of a drag, and a body that reads it recomposes the session beneath once per frame. Composition sees booleans that change twice per gesture, the same correction the keyboard inset needed. Verified on the emulator against the sandbox with ui-trace: the panel opens and closes on the two swipes, tapping another session replaces the screen, deleting the open one leaves for the list, the subagent panel is unchanged, and neither swipe does anything on the main screen. ktfmt, compile, lint and the unit tests are clean.
This commit is contained in:
1 parent
ef788b0405
commit
c8bfc958ad
8 files changed
+418
-126
No files matched your search
@@ -1,21 +1,12 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -29,13 +20,11 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -43,126 +32,20 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val OPEN_THRESHOLD = 0.35f
|
||||
private val FLING_THRESHOLD = 400.dp
|
||||
|
||||
/**
|
||||
* Keeps [content] composed while a panel belonging to it moves over from the right.
|
||||
* A session's subagents, listed in the panel [SidePanels] slides over it from the right.
|
||||
*
|
||||
* The root drag handler deliberately sits behind descendants. A horizontal scroller consumes its
|
||||
* drag first, so code blocks, attachments and tool inputs keep their existing gesture. Collapsing
|
||||
* that content, or starting over any ordinary part of the session, gives the gesture back to the
|
||||
* panel; Android's own right-edge Back gesture remains untouched.
|
||||
* [active] is whether the panel is being looked at: the list is fetched then rather than on
|
||||
* composition, since the panel is composed for every session whether or not anybody opens it.
|
||||
*/
|
||||
@Composable
|
||||
fun SubagentPanel(
|
||||
settings: ServerSettings,
|
||||
summary: SessionSummary,
|
||||
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var open by remember(summary.id) { mutableStateOf(false) }
|
||||
var dragging by remember(summary.id) { mutableStateOf(false) }
|
||||
var draggedReveal by remember(summary.id) { mutableFloatStateOf(0f) }
|
||||
val animatedReveal = remember(summary.id) { Animatable(0f) }
|
||||
val reveal = if (dragging) draggedReveal else animatedReveal.value
|
||||
val flingThreshold = with(LocalDensity.current) { FLING_THRESHOLD.toPx() }
|
||||
|
||||
suspend fun startDrag() {
|
||||
animatedReveal.stop()
|
||||
draggedReveal = animatedReveal.value
|
||||
dragging = true
|
||||
}
|
||||
|
||||
suspend fun finishDrag(velocity: Float) {
|
||||
val targetOpen =
|
||||
when {
|
||||
velocity < -flingThreshold -> true
|
||||
velocity > flingThreshold -> false
|
||||
else -> draggedReveal >= OPEN_THRESHOLD
|
||||
}
|
||||
open = targetOpen
|
||||
animatedReveal.snapTo(draggedReveal)
|
||||
dragging = false
|
||||
animatedReveal.animateTo(if (targetOpen) 1f else 0f)
|
||||
}
|
||||
|
||||
fun setOpen(targetOpen: Boolean) {
|
||||
open = targetOpen
|
||||
scope.launch { animatedReveal.animateTo(if (targetOpen) 1f else 0f) }
|
||||
}
|
||||
|
||||
BackHandler(enabled = open) { setOpen(false) }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val panelWidth = maxWidth * 0.88f
|
||||
val panelWidthPx = constraints.maxWidth * 0.88f
|
||||
val dragState = rememberDraggableState { delta ->
|
||||
draggedReveal =
|
||||
(draggedReveal - delta / panelWidthPx.coerceAtLeast(1f)).coerceIn(0f, 1f)
|
||||
}
|
||||
val drag =
|
||||
Modifier.draggable(
|
||||
state = dragState,
|
||||
orientation = Orientation.Horizontal,
|
||||
onDragStarted = { startDrag() },
|
||||
onDragStopped = { velocity -> finishDrag(velocity) },
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.then(drag)
|
||||
.then(if (reveal > 0f) Modifier.clearAndSetSemantics {} else Modifier)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (reveal > 0f) {
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
.alpha(reveal * 0.32f)
|
||||
.background(MaterialTheme.colorScheme.scrim)
|
||||
.semantics { contentDescription = "Dismiss subagent panel" }
|
||||
.clickable { setOpen(false) }
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier =
|
||||
Modifier.align(Alignment.CenterEnd)
|
||||
.width(panelWidth)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer { translationX = size.width * (1f - reveal) }
|
||||
.then(drag),
|
||||
) {
|
||||
SubagentPanelContents(
|
||||
settings = settings,
|
||||
summary = summary,
|
||||
active = open,
|
||||
onClose = { setOpen(false) },
|
||||
onOpenSubagent = onOpenSubagent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentPanelContents(
|
||||
settings: ServerSettings,
|
||||
summary: SessionSummary,
|
||||
active: Boolean,
|
||||
|
||||
Reference in new issue
Block a user