diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 0c3b10e73..501dd69b9 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -1,6 +1,28 @@ package com.droiddeck.launcher.ui import androidx.compose.ui.draw.alpha +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import kotlin.math.roundToInt +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.offset @@ -42,9 +64,7 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -195,6 +215,8 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha var androidAppsExpanded by rememberSaveable { mutableStateOf(false) } var appToChooseDisplay by remember { mutableStateOf(null) } var confirmStop by remember { mutableStateOf(false) } + var sheetCoords by remember { mutableStateOf(null) } + var stopCoords by remember { mutableStateOf(null) } val pageScroll = remember { List(DRAWER_PAGES) { ScrollState(0) } } val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil") val focus = remember { DrawerFocus() } @@ -240,10 +262,10 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha enter = slideInHorizontally(Motion.sp(0.8f, Spring.StiffnessLow)) { it } + fadeIn(Motion.tw(220)), exit = slideOutHorizontally(Motion.tw(240)) { it } + fadeOut(Motion.tw(200)), ) { + FocusGlideHost(Modifier.fillMaxHeight().width(sheetWidth)) { Box(Modifier.fillMaxSize().onGloballyPositioned { sheetCoords = it }) { Column( modifier = Modifier - .fillMaxHeight() - .width(sheetWidth) + .fillMaxSize() .background(pal.background.copy(alpha = 0.97f)) .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {} .focusGroup() @@ -255,7 +277,7 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = if (short) 40.dp else 44.dp)) { Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = if (short) 18.sp else 20.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true } + StopSessionButton(modifier = focus.track(page, "stop").onGloballyPositioned { stopCoords = it }) { host.open = null; confirmStop = true } } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } @@ -437,6 +459,18 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha } } } } + StopConfirm( + open = confirmStop, + pill = sheetCoords?.let { sc -> stopCoords?.takeIf { it.isAttached && sc.isAttached }?.let { sc.localBoundingBoxOf(it, clipBounds = false) } }, + controllerActive = controllerActive, + onCancel = { confirmStop = false }, + onStop = { onScreen -> + confirmStop = false + val from = a.onStopFrom + if (from != null) from(onScreen) else a.onStop() + }, + ) + } } } } @@ -457,44 +491,6 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha onDismiss = { appToChooseDisplay = null }, ) } - if (confirmStop) { - val cancelFocus = remember { FocusRequester() } - val cancel = { confirmStop = false } - // Where Stop sits on screen: the dialog is a window of its own, so its bounds are taken - // against the screen and the session's overlay converts them back. - var stopOnScreen by remember { mutableStateOf(null) } - val stop = { - confirmStop = false - val from = a.onStopFrom - if (from != null) from(stopOnScreen) else a.onStop() - } - LaunchedEffect(controllerActive) { - if (controllerActive) { - androidx.compose.runtime.withFrameNanos { } - runCatching { cancelFocus.requestFocus() } - } - } - AlertDialog( - onDismissRequest = cancel, - modifier = Modifier.controllerBack(onBack = cancel), - title = { Text("Stop session?") }, - confirmButton = { - val dialogView = LocalView.current - TextButton( - onClick = stop, - modifier = Modifier.controllerConfirm(onClick = stop).onGloballyPositioned { c -> - val at = IntArray(2).also { dialogView.rootView.getLocationOnScreen(it) } - stopOnScreen = c.boundsInWindow().translate(at[0].toFloat(), at[1].toFloat()) - }, - colors = ButtonDefaults.textButtonColors(contentColor = colors.error), - ) { Text("Stop") } - }, - dismissButton = { - TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus) - .controllerConfirm(onClick = cancel)) { Text("Cancel") } - }, - ) - } } @Composable @@ -788,3 +784,220 @@ private const val DRAWER_TAB_LEAN_DP = 12f private const val DRAWER_TAB_SIDE_SCALE = 0.5f private const val DRAWER_TAB_SIDE_ALPHA = 0.7f + +/** Room between the Stop pill and the box that steps out under it: the pull. */ +private val StepPull = 6.dp +private val StepBoxCorner = 16.dp +private val StepFillet = 10.dp +private val StepBoxMaxWidth = 264.dp + +/** + * The stair-step outline: the pill [p] on top, a box under it from [left] to the pill's right edge + * and down to [bottom], the pull between them [pullTop] (the box's top). Right edges are flush and + * the inside corner where the pull meets the box is rounded the other way. While the box is still + * no wider than the pill it is just the pill, stretched down. + */ +private fun stepPath(path: Path, p: Rect, left: Float, bottom: Float, pullTop: Float, box: Float, fillet: Float) { + path.reset() + val r = p.right; val tl = p.left; val tt = p.top + val pb = maxOf(bottom, p.bottom) + val l = minOf(left, tl) + val pt = minOf(pullTop, pb) + val sw = tl - l + val h = (pb - pt).coerceAtLeast(0f) + val rt = minOf(p.height / 2f, p.width / 2f) + val rbr = minOf(box, (pb - tt) / 2f) + val rbl = minOf(lerp(minOf(box, (pb - tt) / 2f), minOf(box, h / 2f), (sw / box).coerceIn(0f, 1f)), (r - l) / 2f) + val rpl = minOf(box, sw / 2f, h / 2f) + val f = minOf(fillet, sw / 2f) + val yl = minOf(pt + rpl, pb - rbl) + fun corner(rect: Rect, start: Float, sweep: Float, endX: Float, endY: Float) { + if (rect.width < 0.5f) path.lineTo(endX, endY) else path.arcTo(rect, start, sweep, false) + } + path.moveTo(tl + rt, tt) + path.lineTo(r - rt, tt) + corner(Rect(r - 2 * rt, tt, r, tt + 2 * rt), -90f, 90f, r, tt + rt) + path.lineTo(r, pb - rbr) + corner(Rect(r - 2 * rbr, pb - 2 * rbr, r, pb), 0f, 90f, r - rbr, pb) + path.lineTo(l + rbl, pb) + corner(Rect(l, pb - 2 * rbl, l + 2 * rbl, pb), 90f, 90f, l, pb - rbl) + path.lineTo(l, yl) + corner(Rect(l, pt, l + 2 * rpl, pt + 2 * rpl), 180f, 90f, l + rpl, pt) + path.lineTo(tl - f, pt) + corner(Rect(tl - 2 * f, pt - 2 * f, tl, pt), 90f, -90f, tl, pt - f) + path.lineTo(tl, tt + rt) + corner(Rect(tl, tt, tl + 2 * rt, tt + 2 * rt), 180f, 90f, tl + rt, tt) + path.close() +} + +/** + * Stop's confirm, grown out of the Stop pill at [pill] (px, in the sheet): a pull the pill's width + * drops out of it and a wider box steps out to the left under it for the choices, right edges + * flush, so Stop lands under the pill. Cancel, B and a tap outside fold it back into the pill, + * width first. [onStop] gets Stop's bounds on screen for the flood. + */ +@Composable +private fun StopConfirm( + open: Boolean, + pill: Rect?, + controllerActive: Boolean, + onCancel: () -> Unit, + onStop: (Rect?) -> Unit, +) { + val colors = MaterialTheme.colorScheme + val density = LocalDensity.current + val left = remember { Animatable(0f) } + val bottom = remember { Animatable(0f) } + val tint = remember { Animatable(0f) } + val dim = remember { Animatable(0f) } + val items = remember { List(3) { Animatable(0f) } } + // Composed from the first open until it has folded back into the pill. + var shown by remember { mutableStateOf(false) } + var grown by remember { mutableStateOf(false) } + var content by remember { mutableStateOf(null) } + val cancelFocus = remember { FocusRequester() } + var stopFocused by remember { mutableStateOf(false) } + var cancelFocused by remember { mutableStateOf(false) } + var stopCoords by remember { mutableStateOf(null) } + val view = LocalView.current + if (!open && !shown) return + val pull = with(density) { StepPull.toPx() } + + LaunchedEffect(open, pill, content) { + val p = pill ?: return@LaunchedEffect + val c = content ?: return@LaunchedEffect + val boxLeft = (p.right - c.width).coerceAtLeast(0f) + val boxBottom = p.bottom + pull + c.height + if (open) { + shown = true + if (!grown) { + left.snapTo(p.left); bottom.snapTo(p.bottom); tint.snapTo(0f) + items.forEach { it.snapTo(0f) } + grown = true + } + coroutineScope { + launch { dim.animateTo(1f, Motion.tw(260)) } + launch { tint.animateTo(1f, Motion.tw(200)) } + launch { bottom.animateTo(boxBottom, Motion.sp(0.7f, 380f)) } + launch { delay(Motion.ms(110).toLong()); left.animateTo(boxLeft, Motion.sp(0.55f, 300f)) } + items.forEachIndexed { i, a -> + launch { delay(Motion.ms(260 + i * 55).toLong()); a.animateTo(1f, Motion.tw(220)) } + } + if (controllerActive) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { cancelFocus.requestFocus() } + } + } + } else if (grown) { + coroutineScope { + items.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); dim.animateTo(0f, Motion.tw(300)) } + launch { delay(Motion.ms(60).toLong()); left.animateTo(p.left, Motion.sp(1f, 420f)) } + launch { delay(Motion.ms(160).toLong()); bottom.animateTo(p.bottom, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(200).toLong()); tint.animateTo(0f, Motion.tw(200)) } + } + grown = false + shown = false + content = null + } + } + val p = pill ?: return + + Box(Modifier.fillMaxSize()) { + // The rest of the sheet dims behind it, and a tap there cancels. + Box( + Modifier.fillMaxSize().graphicsLayer { alpha = dim.value }.background(Color(0x59000000)) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = open, onClick = onCancel), + ) + val path = remember { Path() } + val boxCorner = with(density) { StepBoxCorner.toPx() } + val fillet = with(density) { StepFillet.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val restFill = colors.surface + val hotFill = lerp(colors.surface, colors.error, 0.22f) + Canvas(Modifier.fillMaxSize()) { + if (!grown) return@Canvas + stepPath(path, p, left.value, bottom.value, p.bottom + pull, boxCorner, fillet) + drawPath(path, lerp(hotFill, restFill, tint.value)) + drawPath(path, colors.error.copy(alpha = lerp(0.9f, 0.55f, tint.value)), style = Stroke(rim)) + } + // The pill's label rides on top, as the pull's handle. + Row( + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.offset { IntOffset(p.left.roundToInt(), p.top.roundToInt()) } + .size(with(density) { p.width.toDp() }, with(density) { p.height.toDp() }) + .padding(start = 12.dp, end = 14.dp), + ) { + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = null, tint = colors.error, modifier = Modifier.size(18.dp)) + Text("Stop", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.error) + } + val maxW = with(density) { StepBoxMaxWidth.toPx() }.coerceAtMost(p.right) + Column( + modifier = Modifier + .offset { IntOffset((p.right - (content?.width ?: maxW.roundToInt())).roundToInt(), (p.bottom + pull).roundToInt()) } + .width(with(density) { maxW.toDp() }) + .onSizeChanged { if (content != it) content = it } + .controllerBack(onCancel) + .onPreviewKeyEvent { e -> + // Keep a controller in here, as the dialog's own window used to. + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (e.key) { + Key.DirectionUp, Key.DirectionDown -> true + Key.DirectionLeft -> cancelFocused + Key.DirectionRight -> stopFocused + else -> false + } + } + .padding(start = 18.dp, end = 12.dp, top = 14.dp, bottom = 12.dp), + ) { + Text( + "Stop session?", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, + modifier = Modifier.graphicsLayer { alpha = items[0].value; translationY = (1f - items[0].value) * 6.dp.toPx() }, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) { + ConfirmChoice( + "Cancel", danger = false, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[1].value; translationY = (1f - items[1].value) * 6.dp.toPx() } + .focusRequester(cancelFocus).onFocusChanged { cancelFocused = it.isFocused }, + onClick = onCancel, + ) + ConfirmChoice( + "Stop", danger = true, enabled = open, + modifier = Modifier.graphicsLayer { alpha = items[2].value; translationY = (1f - items[2].value) * 6.dp.toPx() } + .onFocusChanged { stopFocused = it.isFocused } + .onGloballyPositioned { stopCoords = it }, + ) { + val at = IntArray(2).also { view.rootView.getLocationOnScreen(it) } + onStop(stopCoords?.takeIf { it.isAttached }?.boundsInWindow()?.translate(at[0].toFloat(), at[1].toFloat())) + } + } + } + } +} + +/** One of the confirm's two choices: a capsule that fills when focused; Stop in the danger red. */ +@Composable +private fun ConfirmChoice(label: String, danger: Boolean, enabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val shape = RoundedCornerShape(20.dp) + val accent = if (danger) colors.error else pal.signal + Box( + contentAlignment = Alignment.Center, + modifier = modifier.heightIn(min = 40.dp).clip(shape) + .background(if (hot) accent.copy(alpha = if (danger) 0.18f else 0.14f) else Color.Transparent) + .glideBorder(hot, shape, accent) + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, onClick = onClick) + .controllerConfirm(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (danger) colors.error else colors.onBackground) + } +}