ActionSheet.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.components |
| 2 | ||
| 3 | import androidx.activity.compose.BackHandler | |
| 4 | import androidx.compose.foundation.background | |
| 5 | import androidx.compose.foundation.clickable | |
| 6 | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 7 | import androidx.compose.foundation.layout.Arrangement | |
| 8 | import androidx.compose.foundation.layout.Box | |
| 9 | import androidx.compose.foundation.lazy.LazyColumn | |
| 10 | import androidx.compose.foundation.lazy.itemsIndexed | |
| 11 | import androidx.compose.foundation.layout.Column | |
| 12 | import androidx.compose.foundation.layout.Row | |
| 13 | import androidx.compose.foundation.layout.Spacer | |
| 14 | import androidx.compose.foundation.layout.fillMaxHeight | |
| 15 | import androidx.compose.foundation.layout.fillMaxSize | |
| 16 | import androidx.compose.foundation.layout.height | |
| 17 | import androidx.compose.foundation.layout.padding | |
| 18 | import androidx.compose.foundation.layout.fillMaxWidth | |
| 19 | import androidx.compose.foundation.layout.width | |
| 20 | import androidx.compose.foundation.layout.widthIn | |
| 21 | import androidx.compose.runtime.Composable | |
| 22 | import androidx.compose.runtime.LaunchedEffect | |
| 23 | import androidx.compose.runtime.getValue | |
| 24 | import androidx.compose.runtime.mutableStateOf | |
| 25 | import androidx.compose.runtime.remember | |
| 26 | import androidx.compose.runtime.rememberUpdatedState | |
| 27 | import androidx.compose.ui.Alignment | |
| 28 | import androidx.compose.ui.Modifier | |
| 29 | import androidx.compose.ui.focus.FocusRequester | |
| 30 | import androidx.compose.ui.focus.focusProperties | |
| 31 | import androidx.compose.foundation.focusGroup | |
| 32 | // Maintainer, 2026-10-29: tap handling for the scrim and panel (taps fell through). | |
| 33 | import androidx.compose.foundation.gestures.detectTapGestures | |
| 34 | import androidx.compose.ui.input.pointer.pointerInput | |
| 35 | import androidx.compose.ui.focus.focusRequester | |
| 36 | import androidx.compose.ui.graphics.Color | |
| 37 | import androidx.compose.ui.input.key.Key | |
| 38 | import androidx.compose.ui.input.key.KeyEventType | |
| 39 | import androidx.compose.ui.input.key.key | |
| 40 | import androidx.compose.ui.input.key.onPreviewKeyEvent | |
| 41 | import androidx.compose.ui.input.key.type | |
| 42 | import androidx.compose.ui.text.style.TextOverflow | |
| 43 | import androidx.compose.ui.unit.dp | |
| 44 | import androidx.tv.material3.MaterialTheme | |
| 45 | import androidx.tv.material3.Text | |
| 46 | import dev.stave.ui.theme.OnMuted | |
| 47 | import dev.stave.ui.theme.OnPrimary | |
| 48 | import dev.stave.ui.theme.OnQuiet | |
| 49 | import dev.stave.ui.theme.SurfaceBase | |
| 50 | import dev.stave.ui.theme.SurfaceRaised | |
| 51 | import dev.stave.ui.theme.isCompactHeight | |
| 52 | import dev.stave.ui.theme.withInterDigits | |
| 53 | ||
| 54 | /** | |
| 55 | * A single tappable action within an [ActionSheet]. | |
| 56 | * [glyph] is a short leading character (▶, +, ⤵ etc.) — deliberately unicode rather than | |
| 57 | * vector icons to keep the sheet dependency-free and consistent with action labels elsewhere. | |
| 58 | */ | |
| 59 | data class SheetAction( | |
| 60 | val glyph: String, | |
| 61 | val label: String, | |
| 62 | val onClick: () -> Unit, | |
| 63 | ) | |
| 64 | ||
| 65 | /** | |
| 66 | * Right-anchored focusable action sheet. Shown as a full-screen overlay with a dim scrim over | |
| 67 | * the underlying content and a 480dp panel on the right. Dismissable via BackHandler or the | |
| 68 | * Cancel row. | |
| 69 | * | |
| 70 | * Design notes: | |
| 71 | * - **Focus is trapped horizontally**: D-pad Left/Right are swallowed at the root so the user | |
| 72 | * cannot spatially escape the sheet into the dimmed background content. Up/Down navigate | |
| 73 | * between sheet rows as expected. | |
| 74 | * - **No stuck-in-sheet risk**: every sheet auto-appends a Cancel row at the bottom, and the | |
| 75 | * remote Back button always dismisses. So the user always has at least two escape routes. | |
| 76 | * Trapping horizontal focus is safe because of this. | |
| 77 | * - Initial focus lands on the first action (usually "Play"), matching §4.5. | |
| 78 | * - Scrim color is a 55% black veil — intent is "the world behind is dimmed," not "a new | |
| 79 | * surface is layered on top." | |
| 80 | */ | |
| 81 | @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) | |
| 82 | @Composable | |
| 83 | fun ActionSheet( | |
| 84 | title: String, | |
| 85 | subtitle: String? = null, | |
| 86 | actions: List<SheetAction>, | |
| 87 | onDismiss: () -> Unit, | |
| 88 | bottomContent: @Composable (() -> Unit)? = null, | |
| 89 | ) { | |
| 90 | BackHandler(onBack = onDismiss) | |
| 91 | ||
| 92 | val firstFocus = remember { FocusRequester() } | |
| 93 | // Long-press dismiss guard. When the sheet is opened by a long-press | |
| 94 | // (DpadCenter held ≥500ms), the user is still holding the select key | |
| 95 | // when the sheet appears. If we don't swallow the first release (and | |
| 96 | // any auto-repeat KeyDowns before it), the focused action item fires | |
| 97 | // immediately — the user never gets to see the menu. | |
| 98 | // | |
| 99 | // Strategy: assume long-press if a select KeyDown arrives within 80ms | |
| 100 | // of composition (i.e. the key was already held). If so, consume | |
| 101 | // everything until the first KeyUp. If no select KeyDown arrives in | |
| 102 | // that window, the sheet was opened by a normal click and the guard | |
| 103 | // is disarmed immediately — no double-press needed. | |
| 104 | val guardActive = remember { mutableStateOf(true) } | |
| 105 | val guardArmed = remember { mutableStateOf(false) } | |
| 106 | LaunchedEffect(Unit) { | |
| 107 | // Give 80ms for a held-key KeyDown event to arrive. If it does, | |
| 108 | // guardArmed is set true by the key handler below. If not, the | |
| 109 | // sheet was opened by a normal click and we disarm the guard. | |
| 110 | kotlinx.coroutines.delay(80) | |
| 111 | if (!guardArmed.value) { | |
| 112 | guardActive.value = false | |
| 113 | } | |
| 114 | runCatching { firstFocus.requestFocus() } | |
| 115 | } | |
| 116 | ||
| 117 | // Maintainer, 2026-10-29: phone landscape (~360dp tall) left ~36dp for the | |
| 118 | // action list under 48dp padding + 2-line title + 3-line subtitle. Short | |
| 119 | // viewports get tighter padding/spacing and one-line headers. | |
| 120 | val compactHeight = isCompactHeight() | |
| 121 | // Maintainer, 2026-10-29: latest dismiss callback for the long-lived scrim | |
| 122 | // tap detector below (pointerInput(Unit) would otherwise keep the first one). | |
| 123 | val currentOnDismiss by rememberUpdatedState(onDismiss) | |
| 124 | ||
| 125 | Box( | |
| 126 | Modifier | |
| 127 | .fillMaxSize() | |
| 128 | .background(Color.Black.copy(alpha = 0.55f)) | |
| 129 | // Maintainer, 2026-10-29: the scrim had no pointer handler, so taps on | |
| 130 | // the dimmed area fell through to the screen underneath and | |
| 131 | // tap-outside never dismissed. A tap detector (not clickable) is | |
| 132 | // used deliberately: clickable adds a focus target that is | |
| 133 | // focusable in non-touch mode (TV) plus Enter-key handling, which | |
| 134 | // would change D-pad behaviour; pointerInput adds neither, so TV | |
| 135 | // focus is exactly as before. | |
| 136 | .pointerInput(Unit) { detectTapGestures { currentOnDismiss() } } | |
| 137 | .onPreviewKeyEvent { event -> | |
| 138 | // Swallow horizontal D-pad to trap focus inside the sheet. Without | |
| 139 | // this, pressing Left moves focus into the dimmed content behind | |
| 140 | // the sheet, which reads as "the popup just disappeared" even | |
| 141 | // though it's still technically there. Cancel row + Back button | |
| 142 | // are the legitimate escapes. | |
| 143 | if (event.key == Key.DirectionLeft || event.key == Key.DirectionRight) { | |
| 144 | return@onPreviewKeyEvent true | |
| 145 | } | |
| 146 | val isSelect = event.key == Key.DirectionCenter || | |
| 147 | event.key == Key.Enter || | |
| 148 | event.key == Key.NumPadEnter | |
| 149 | if (!isSelect) return@onPreviewKeyEvent false | |
| 150 | // Guard: if the select key is already held when the sheet | |
| 151 | // mounts (long-press open), swallow everything until the | |
| 152 | // first KeyUp. A KeyDown arriving within 80ms of mount | |
| 153 | // arms the guard; otherwise it's already disarmed. | |
| 154 | if (guardActive.value) { | |
| 155 | if (event.type == KeyEventType.KeyDown) { | |
| 156 | guardArmed.value = true | |
| 157 | } | |
| 158 | if (event.type == KeyEventType.KeyUp && guardArmed.value) { | |
| 159 | guardActive.value = false | |
| 160 | } | |
| 161 | return@onPreviewKeyEvent true | |
| 162 | } | |
| 163 | false | |
| 164 | } | |
| 165 | // Focus trap (same pattern as the Cast picker sheets): the | |
| 166 | // Left/Right swallow above only covers horizontal escapes — | |
| 167 | // Up past the first action row or Down past Cancel could | |
| 168 | // still spatially land focus on an invisible element behind | |
| 169 | // the scrim, "vanishing" the user's cursor. Cancelling exit | |
| 170 | // on the sheet's focus group pins ALL directional navigation | |
| 171 | // inside; Back and the Cancel row remain the escapes. | |
| 172 | .focusProperties { exit = { FocusRequester.Cancel } } | |
| 173 | .focusGroup(), | |
| 174 | contentAlignment = Alignment.CenterEnd, | |
| 175 | ) { | |
| 176 | Column( | |
| 177 | Modifier | |
| 178 | .fillMaxHeight() | |
| 179 | // 480dp was 380dp. The extra width is for long labels — genre | |
| 180 | // names with "/" separators, playlist descriptions, etc. that | |
| 181 | // previously got cut off at a too-narrow single line. | |
| 182 | // Audit 2026-09-02: a fixed 480dp overflowed every phone | |
| 183 | // (~411dp portrait) — the sheet's left edge and its glyph | |
| 184 | // column sat off-screen. Cap at 480, never wider than the | |
| 185 | // screen; TV is unchanged (its screens exceed 480). | |
| 186 | .fillMaxWidth() | |
| 187 | .widthIn(max = 480.dp) | |
| 188 | .background(SurfaceBase) | |
| 189 | // Maintainer, 2026-10-29: the panel consumes its own taps (title, | |
| 190 | // padding, gaps) so they neither reach the scrim's dismiss nor | |
| 191 | // the screen underneath. pointerInput rather than a no-op | |
| 192 | // clickable for the same reason as the scrim: no new focus | |
| 193 | // target on TV. Rows' own clickables still win (they are | |
| 194 | // children and consume the tap first). | |
| 195 | .pointerInput(Unit) { detectTapGestures { } } | |
| 196 | // Maintainer, 2026-10-29: 16dp vertical padding on short (phone | |
| 197 | // landscape) viewports; 48dp otherwise, as before. | |
| 198 | .padding(horizontal = 28.dp, vertical = if (compactHeight) 16.dp else 48.dp), | |
| 199 | ) { | |
| 200 | Text( | |
| 201 | title, | |
| 202 | style = MaterialTheme.typography.titleLarge, | |
| 203 | color = OnPrimary, | |
| 204 | // Maintainer, 2026-10-29: one line on short viewports. | |
| 205 | maxLines = if (compactHeight) 1 else 2, | |
| 206 | overflow = TextOverflow.Ellipsis, | |
| 207 | ) | |
| 208 | if (subtitle != null) { | |
| 209 | Spacer(Modifier.height(4.dp)) | |
| 210 | Text( | |
| 211 | withInterDigits(subtitle), | |
| 212 | style = MaterialTheme.typography.bodyMedium, | |
| 213 | color = OnMuted, | |
| 214 | // Was maxLines=1 — caused "This replaces your current queue | |
| 215 | // with 42 random tracks." and similar subtitles to elipsize | |
| 216 | // in the middle. 3 lines is plenty for any real subtitle. | |
| 217 | // Maintainer, 2026-10-29: one line on short viewports. | |
| 218 | maxLines = if (compactHeight) 1 else 3, | |
| 219 | softWrap = true, | |
| 220 | overflow = TextOverflow.Ellipsis, | |
| 221 | ) | |
| 222 | } | |
| 223 | // Maintainer, 2026-10-29: 12dp on short viewports, 28dp otherwise. | |
| 224 | Spacer(Modifier.height(if (compactHeight) 12.dp else 28.dp)) | |
| 225 | ||
| 226 | // LazyColumn (not Column) so the list scrolls when actions + the | |
| 227 | // trailing Cancel row would exceed the sheet's vertical space. On | |
| 228 | // Android TV this is driven by focus: D-pad Down onto an off-screen | |
| 229 | // row causes LazyColumn to scroll it into view automatically. This | |
| 230 | // is what makes the Cancel row reachable when the genre picker has | |
| 231 | // 5+ options — previously it was drawn below the viewport with no | |
| 232 | // way to scroll to it. | |
| 233 | LazyColumn( | |
| 234 | verticalArrangement = Arrangement.spacedBy(6.dp), | |
| 235 | ) { | |
| 236 | itemsIndexed(actions) { index, action -> | |
| 237 | SheetRow( | |
| 238 | action = action, | |
| 239 | onClick = { | |
| 240 | action.onClick() | |
| 241 | onDismiss() | |
| 242 | }, | |
| 243 | modifier = if (index == 0) { | |
| 244 | Modifier.focusRequester(firstFocus) | |
| 245 | } else { | |
| 246 | Modifier | |
| 247 | }, | |
| 248 | ) | |
| 249 | } | |
| 250 | item { | |
| 251 | Spacer(Modifier.height(12.dp)) | |
| 252 | SheetRow( | |
| 253 | action = SheetAction("✕", "Cancel") { /* no-op, dismiss handles it */ }, | |
| 254 | onClick = onDismiss, | |
| 255 | quiet = true, | |
| 256 | ) | |
| 257 | } | |
| 258 | } | |
| 259 | if (bottomContent != null) { | |
| 260 | Spacer(Modifier.height(32.dp)) | |
| 261 | bottomContent() | |
| 262 | } | |
| 263 | } | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | @Composable | |
| 268 | private fun SheetRow( | |
| 269 | action: SheetAction, | |
| 270 | onClick: () -> Unit, | |
| 271 | modifier: Modifier = Modifier, | |
| 272 | quiet: Boolean = false, | |
| 273 | ) { | |
| 274 | val interaction = remember { MutableInteractionSource() } | |
| 275 | Box( | |
| 276 | modifier | |
| 277 | .fillMaxHeightRowSurface() | |
| 278 | .background(SurfaceRaised) | |
| 279 | .staveFocusable(interaction, cornerRadiusDp = 6) | |
| 280 | .clickable(interaction, indication = null) { onClick() } | |
| 281 | .padding(horizontal = 18.dp, vertical = 14.dp), | |
| 282 | ) { | |
| 283 | Row(verticalAlignment = Alignment.CenterVertically) { | |
| 284 | Text( | |
| 285 | action.glyph, | |
| 286 | style = MaterialTheme.typography.titleMedium, | |
| 287 | color = if (quiet) OnQuiet else OnPrimary, | |
| 288 | modifier = Modifier.width(28.dp), | |
| 289 | ) | |
| 290 | Text( | |
| 291 | withInterDigits(action.label), | |
| 292 | style = MaterialTheme.typography.bodyLarge, | |
| 293 | color = if (quiet) OnQuiet else OnPrimary, | |
| 294 | maxLines = 2, | |
| 295 | softWrap = true, | |
| 296 | overflow = TextOverflow.Ellipsis, | |
| 297 | modifier = Modifier.weight(1f), | |
| 298 | ) | |
| 299 | } | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | /** Keeps each row a consistent width inside the sheet — fills the sheet's | |
| 304 | * interior. Previously this was a fixed 324dp; too narrow for long labels | |
| 305 | * on the new 480dp sheet. */ | |
| 306 | private fun Modifier.fillMaxHeightRowSurface(): Modifier = this.fillMaxWidth() |


