ActionSheet.kt
306 lines · 14.4 KB
Changed in: 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() |


