package dev.stave.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.foundation.focusGroup
// Maintainer, 2026-10-29: tap handling for the scrim and panel (taps fell through).
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceBase
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.isCompactHeight
import dev.stave.ui.theme.withInterDigits
/**
* A single tappable action within an [ActionSheet].
* [glyph] is a short leading character (▶, +, ⤵ etc.) — deliberately unicode rather than
* vector icons to keep the sheet dependency-free and consistent with action labels elsewhere.
*/
data class SheetAction(
val glyph: String,
val label: String,
val onClick: () -> Unit,
)
/**
* Right-anchored focusable action sheet. Shown as a full-screen overlay with a dim scrim over
* the underlying content and a 480dp panel on the right. Dismissable via BackHandler or the
* Cancel row.
*
* Design notes:
* - **Focus is trapped horizontally**: D-pad Left/Right are swallowed at the root so the user
* cannot spatially escape the sheet into the dimmed background content. Up/Down navigate
* between sheet rows as expected.
* - **No stuck-in-sheet risk**: every sheet auto-appends a Cancel row at the bottom, and the
* remote Back button always dismisses. So the user always has at least two escape routes.
* Trapping horizontal focus is safe because of this.
* - Initial focus lands on the first action (usually "Play"), matching §4.5.
* - Scrim color is a 55% black veil — intent is "the world behind is dimmed," not "a new
* surface is layered on top."
*/
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun ActionSheet(
title: String,
subtitle: String? = null,
actions: List<SheetAction>,
onDismiss: () -> Unit,
bottomContent: @Composable (() -> Unit)? = null,
) {
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
// Long-press dismiss guard. When the sheet is opened by a long-press
// (DpadCenter held ≥500ms), the user is still holding the select key
// when the sheet appears. If we don't swallow the first release (and
// any auto-repeat KeyDowns before it), the focused action item fires
// immediately — the user never gets to see the menu.
//
// Strategy: assume long-press if a select KeyDown arrives within 80ms
// of composition (i.e. the key was already held). If so, consume
// everything until the first KeyUp. If no select KeyDown arrives in
// that window, the sheet was opened by a normal click and the guard
// is disarmed immediately — no double-press needed.
val guardActive = remember { mutableStateOf(true) }
val guardArmed = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Give 80ms for a held-key KeyDown event to arrive. If it does,
// guardArmed is set true by the key handler below. If not, the
// sheet was opened by a normal click and we disarm the guard.
kotlinx.coroutines.delay(80)
if (!guardArmed.value) {
guardActive.value = false
}
runCatching { firstFocus.requestFocus() }
}
// Maintainer, 2026-10-29: phone landscape (~360dp tall) left ~36dp for the
// action list under 48dp padding + 2-line title + 3-line subtitle. Short
// viewports get tighter padding/spacing and one-line headers.
val compactHeight = isCompactHeight()
// Maintainer, 2026-10-29: latest dismiss callback for the long-lived scrim
// tap detector below (pointerInput(Unit) would otherwise keep the first one).
val currentOnDismiss by rememberUpdatedState(onDismiss)
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f))
// Maintainer, 2026-10-29: the scrim had no pointer handler, so taps on
// the dimmed area fell through to the screen underneath and
// tap-outside never dismissed. A tap detector (not clickable) is
// used deliberately: clickable adds a focus target that is
// focusable in non-touch mode (TV) plus Enter-key handling, which
// would change D-pad behaviour; pointerInput adds neither, so TV
// focus is exactly as before.
.pointerInput(Unit) { detectTapGestures { currentOnDismiss() } }
.onPreviewKeyEvent { event ->
// Swallow horizontal D-pad to trap focus inside the sheet. Without
// this, pressing Left moves focus into the dimmed content behind
// the sheet, which reads as "the popup just disappeared" even
// though it's still technically there. Cancel row + Back button
// are the legitimate escapes.
if (event.key == Key.DirectionLeft || event.key == Key.DirectionRight) {
return@onPreviewKeyEvent true
}
val isSelect = event.key == Key.DirectionCenter ||
event.key == Key.Enter ||
event.key == Key.NumPadEnter
if (!isSelect) return@onPreviewKeyEvent false
// Guard: if the select key is already held when the sheet
// mounts (long-press open), swallow everything until the
// first KeyUp. A KeyDown arriving within 80ms of mount
// arms the guard; otherwise it's already disarmed.
if (guardActive.value) {
if (event.type == KeyEventType.KeyDown) {
guardArmed.value = true
}
if (event.type == KeyEventType.KeyUp && guardArmed.value) {
guardActive.value = false
}
return@onPreviewKeyEvent true
}
false
}
// Focus trap (same pattern as the Cast picker sheets): the
// Left/Right swallow above only covers horizontal escapes —
// Up past the first action row or Down past Cancel could
// still spatially land focus on an invisible element behind
// the scrim, "vanishing" the user's cursor. Cancelling exit
// on the sheet's focus group pins ALL directional navigation
// inside; Back and the Cancel row remain the escapes.
.focusProperties { exit = { FocusRequester.Cancel } }
.focusGroup(),
contentAlignment = Alignment.CenterEnd,
) {
Column(
Modifier
.fillMaxHeight()
// 480dp was 380dp. The extra width is for long labels — genre
// names with "/" separators, playlist descriptions, etc. that
// previously got cut off at a too-narrow single line.
// Audit 2026-09-02: a fixed 480dp overflowed every phone
// (~411dp portrait) — the sheet's left edge and its glyph
// column sat off-screen. Cap at 480, never wider than the
// screen; TV is unchanged (its screens exceed 480).
.fillMaxWidth()
.widthIn(max = 480.dp)
.background(SurfaceBase)
// Maintainer, 2026-10-29: the panel consumes its own taps (title,
// padding, gaps) so they neither reach the scrim's dismiss nor
// the screen underneath. pointerInput rather than a no-op
// clickable for the same reason as the scrim: no new focus
// target on TV. Rows' own clickables still win (they are
// children and consume the tap first).
.pointerInput(Unit) { detectTapGestures { } }
// Maintainer, 2026-10-29: 16dp vertical padding on short (phone
// landscape) viewports; 48dp otherwise, as before.
.padding(horizontal = 28.dp, vertical = if (compactHeight) 16.dp else 48.dp),
) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
// Maintainer, 2026-10-29: one line on short viewports.
maxLines = if (compactHeight) 1 else 2,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Spacer(Modifier.height(4.dp))
Text(
withInterDigits(subtitle),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
// Was maxLines=1 — caused "This replaces your current queue
// with 42 random tracks." and similar subtitles to elipsize
// in the middle. 3 lines is plenty for any real subtitle.
// Maintainer, 2026-10-29: one line on short viewports.
maxLines = if (compactHeight) 1 else 3,
softWrap = true,
overflow = TextOverflow.Ellipsis,
)
}
// Maintainer, 2026-10-29: 12dp on short viewports, 28dp otherwise.
Spacer(Modifier.height(if (compactHeight) 12.dp else 28.dp))
// LazyColumn (not Column) so the list scrolls when actions + the
// trailing Cancel row would exceed the sheet's vertical space. On
// Android TV this is driven by focus: D-pad Down onto an off-screen
// row causes LazyColumn to scroll it into view automatically. This
// is what makes the Cancel row reachable when the genre picker has
// 5+ options — previously it was drawn below the viewport with no
// way to scroll to it.
LazyColumn(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(actions) { index, action ->
SheetRow(
action = action,
onClick = {
action.onClick()
onDismiss()
},
modifier = if (index == 0) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
},
)
}
item {
Spacer(Modifier.height(12.dp))
SheetRow(
action = SheetAction("✕", "Cancel") { /* no-op, dismiss handles it */ },
onClick = onDismiss,
quiet = true,
)
}
}
if (bottomContent != null) {
Spacer(Modifier.height(32.dp))
bottomContent()
}
}
}
}
@Composable
private fun SheetRow(
action: SheetAction,
onClick: () -> Unit,
modifier: Modifier = Modifier,
quiet: Boolean = false,
) {
val interaction = remember { MutableInteractionSource() }
Box(
modifier
.fillMaxHeightRowSurface()
.background(SurfaceRaised)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 18.dp, vertical = 14.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
action.glyph,
style = MaterialTheme.typography.titleMedium,
color = if (quiet) OnQuiet else OnPrimary,
modifier = Modifier.width(28.dp),
)
Text(
withInterDigits(action.label),
style = MaterialTheme.typography.bodyLarge,
color = if (quiet) OnQuiet else OnPrimary,
maxLines = 2,
softWrap = true,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
/** Keeps each row a consistent width inside the sheet — fills the sheet's
* interior. Previously this was a fixed 324dp; too narrow for long labels
* on the new 480dp sheet. */
private fun Modifier.fillMaxHeightRowSurface(): Modifier = this.fillMaxWidth()