SystemImeTextDialog.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.border | |
| 6 | import androidx.compose.foundation.clickable | |
| 7 | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 8 | import androidx.compose.foundation.interaction.collectIsFocusedAsState | |
| 9 | // Maintainer, 2026-10-29: Cancel/confirm button row + TV focus trap. | |
| 10 | import androidx.compose.foundation.focusGroup | |
| 11 | import androidx.compose.foundation.layout.Arrangement | |
| 12 | import androidx.compose.foundation.layout.Row | |
| 13 | import androidx.compose.ui.focus.focusProperties | |
| 14 | import dev.stave.ui.theme.SurfaceHover | |
| 15 | import androidx.compose.foundation.layout.Box | |
| 16 | import androidx.compose.foundation.layout.Column | |
| 17 | import androidx.compose.foundation.layout.Spacer | |
| 18 | import androidx.compose.foundation.layout.fillMaxSize | |
| 19 | import androidx.compose.foundation.layout.fillMaxWidth | |
| 20 | import androidx.compose.foundation.layout.height | |
| 21 | import androidx.compose.foundation.layout.padding | |
| 22 | import androidx.compose.foundation.layout.widthIn | |
| 23 | import androidx.compose.foundation.shape.RoundedCornerShape | |
| 24 | import androidx.compose.foundation.text.BasicTextField | |
| 25 | import androidx.compose.foundation.text.KeyboardActions | |
| 26 | import androidx.compose.foundation.text.KeyboardOptions | |
| 27 | import androidx.compose.runtime.Composable | |
| 28 | import androidx.compose.runtime.LaunchedEffect | |
| 29 | import androidx.compose.runtime.getValue | |
| 30 | import androidx.compose.runtime.mutableStateOf | |
| 31 | import androidx.compose.runtime.remember | |
| 32 | import androidx.compose.runtime.setValue | |
| 33 | import androidx.compose.ui.Alignment | |
| 34 | import androidx.compose.ui.Modifier | |
| 35 | import androidx.compose.ui.draw.clip | |
| 36 | import androidx.compose.ui.focus.FocusRequester | |
| 37 | import androidx.compose.ui.focus.focusRequester | |
| 38 | import androidx.compose.ui.graphics.Color | |
| 39 | import androidx.compose.ui.graphics.SolidColor | |
| 40 | import androidx.compose.ui.text.AnnotatedString | |
| 41 | import androidx.compose.ui.text.SpanStyle | |
| 42 | import androidx.compose.ui.text.buildAnnotatedString | |
| 43 | import androidx.compose.ui.text.input.ImeAction | |
| 44 | import androidx.compose.ui.text.input.OffsetMapping | |
| 45 | import androidx.compose.ui.text.input.TransformedText | |
| 46 | import androidx.compose.ui.text.input.VisualTransformation | |
| 47 | import androidx.compose.ui.text.style.TextOverflow | |
| 48 | import androidx.compose.ui.text.withStyle | |
| 49 | import androidx.compose.ui.unit.dp | |
| 50 | import androidx.tv.material3.MaterialTheme | |
| 51 | import androidx.tv.material3.Text | |
| 52 | import dev.stave.ui.theme.AccentAmber | |
| 53 | import dev.stave.ui.theme.OnMuted | |
| 54 | import dev.stave.ui.theme.OnPrimary | |
| 55 | import dev.stave.ui.theme.OnQuiet | |
| 56 | import dev.stave.ui.theme.SurfaceRaised | |
| 57 | import dev.stave.ui.theme.staveInterFamily | |
| 58 | import dev.stave.ui.theme.withInterDigits | |
| 59 | ||
| 60 | /** | |
| 61 | * Modal text-input dialog backed by a [BasicTextField] that summons the | |
| 62 | * system IME. | |
| 63 | * | |
| 64 | * Replaces the prior in-house keyboard pattern. The system IME provides word | |
| 65 | * suggestions, voice input, and IME-driven editor controls that an embedded | |
| 66 | * keyboard composable cannot replicate (the IME runs in its own process and | |
| 67 | * can't be skinned from inside a host app). | |
| 68 | * | |
| 69 | * Call signature is intentionally identical to the prior `KeyboardTextDialog` | |
| 70 | * so the three callsites — AddGenreDialog, SavePlaylistScreen, SearchScreen — | |
| 71 | * only need a rename. | |
| 72 | * | |
| 73 | * The dialog owns its text buffer locally (initialized to [initialText]) and | |
| 74 | * reports back to the caller only on confirm or dismiss. Pressing Done / | |
| 75 | * Search on the system IME triggers [onConfirm] with the trimmed text; | |
| 76 | * pressing Back triggers [onDismiss]. The field auto-focuses on appearance | |
| 77 | * so the IME pops up immediately. | |
| 78 | */ | |
| 79 | // Maintainer, 2026-10-29: opt-in for the focus-exit trap, as ActionSheet. | |
| 80 | @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) | |
| 81 | @Composable | |
| 82 | fun SystemImeTextDialog( | |
| 83 | title: String, | |
| 84 | placeholder: String, | |
| 85 | confirmLabel: String, | |
| 86 | onConfirm: (String) -> Unit, | |
| 87 | onDismiss: () -> Unit, | |
| 88 | initialText: String = "", | |
| 89 | /** Optional helper line shown beneath the title. Null = nothing rendered. */ | |
| 90 | subtitle: String? = null, | |
| 91 | /** True = render the input verbatim in one uniform typeface with no | |
| 92 | * visual transformation. Required for URLs and identifiers: the | |
| 93 | * default digit transform sets digits in a different font than | |
| 94 | * letters, which makes "<host>" and its slashes visually | |
| 95 | * incoherent (maintainer report, 2026-08-29). */ | |
| 96 | plainInput: Boolean = false, | |
| 97 | /** Maintainer, 2026-10-29: true = confirming an empty field passes "" to | |
| 98 | * [onConfirm] (callers that treat empty as "clear"/"reset"). False = | |
| 99 | * an empty confirm is ignored, as before. */ | |
| 100 | allowEmpty: Boolean = false, | |
| 101 | ) { | |
| 102 | var text by remember { mutableStateOf(initialText) } | |
| 103 | // Maintainer, 2026-10-29: one confirm path shared by the IME Done key and | |
| 104 | // the new on-screen confirm button; honours [allowEmpty]. | |
| 105 | val confirm: () -> Unit = { | |
| 106 | val trimmed = text.trim() | |
| 107 | if (trimmed.isNotEmpty() || allowEmpty) onConfirm(trimmed) | |
| 108 | } | |
| 109 | val fieldFocus = remember { FocusRequester() } | |
| 110 | val interaction = remember { MutableInteractionSource() } | |
| 111 | val focused by interaction.collectIsFocusedAsState() | |
| 112 | ||
| 113 | // Per the app-wide "Fraunces everywhere except numerals" directive, the | |
| 114 | // text field paints letters in the Fraunces titleMedium style but routes | |
| 115 | // digit runs through Inter. The transformation is identity-mapped — it | |
| 116 | // only restyles glyphs, never inserts or removes characters — so cursor | |
| 117 | // positions, selection, and IME composition stay aligned with the | |
| 118 | // underlying String buffer. | |
| 119 | val interFamily = staveInterFamily() | |
| 120 | val digitTransform = remember(interFamily) { | |
| 121 | VisualTransformation { input -> | |
| 122 | TransformedText( | |
| 123 | text = buildInterDigitSpans(input, interFamily), | |
| 124 | offsetMapping = OffsetMapping.Identity, | |
| 125 | ) | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | BackHandler(onBack = onDismiss) | |
| 130 | ||
| 131 | LaunchedEffect(Unit) { | |
| 132 | runCatching { fieldFocus.requestFocus() } | |
| 133 | } | |
| 134 | ||
| 135 | Box( | |
| 136 | Modifier | |
| 137 | .fillMaxSize() | |
| 138 | .background(Color.Black.copy(alpha = 0.75f)) | |
| 139 | .clickable( | |
| 140 | interactionSource = remember { MutableInteractionSource() }, | |
| 141 | indication = null, | |
| 142 | onClick = { /* swallow scrim taps */ }, | |
| 143 | ) | |
| 144 | // Maintainer, 2026-10-29: TV focus trap (same as ActionSheet) — D-pad | |
| 145 | // escaped to the screen behind (e.g. PlaybackSettings rows). | |
| 146 | // Back and the Cancel button remain the exits. | |
| 147 | .focusProperties { exit = { FocusRequester.Cancel } } | |
| 148 | .focusGroup(), | |
| 149 | // Top-anchored rather than vertically centered: the system IME pops | |
| 150 | // up from the bottom of the screen when the field auto-focuses, and | |
| 151 | // covered the lower half of a centered dialog. Top alignment with | |
| 152 | // breathing-room padding keeps the entire dialog above the IME edge | |
| 153 | // regardless of IME height (Gboard, Leanback, OEMs all differ). | |
| 154 | contentAlignment = Alignment.TopCenter, | |
| 155 | ) { | |
| 156 | Column( | |
| 157 | Modifier | |
| 158 | .padding(top = 64.dp) | |
| 159 | .fillMaxWidth(0.92f) | |
| 160 | .widthIn(max = 760.dp) | |
| 161 | .clip(RoundedCornerShape(16.dp)) | |
| 162 | .background(SurfaceRaised) | |
| 163 | .padding(horizontal = 32.dp, vertical = 28.dp), | |
| 164 | horizontalAlignment = Alignment.CenterHorizontally, | |
| 165 | ) { | |
| 166 | Text( | |
| 167 | title, | |
| 168 | style = MaterialTheme.typography.titleLarge, | |
| 169 | color = OnPrimary, | |
| 170 | ) | |
| 171 | if (subtitle != null) { | |
| 172 | Spacer(Modifier.height(8.dp)) | |
| 173 | Text( | |
| 174 | withInterDigits(subtitle), | |
| 175 | style = MaterialTheme.typography.bodyMedium, | |
| 176 | color = OnMuted, | |
| 177 | ) | |
| 178 | } | |
| 179 | Spacer(Modifier.height(20.dp)) | |
| 180 | ||
| 181 | Box( | |
| 182 | Modifier | |
| 183 | .fillMaxWidth() | |
| 184 | .background( | |
| 185 | if (focused) AccentAmber.copy(alpha = 0.18f) else Color.Black.copy(alpha = 0.35f), | |
| 186 | RoundedCornerShape(8.dp), | |
| 187 | ) | |
| 188 | .border( | |
| 189 | width = if (focused) 2.dp else 1.dp, | |
| 190 | color = if (focused) AccentAmber else OnQuiet, | |
| 191 | shape = RoundedCornerShape(8.dp), | |
| 192 | ) | |
| 193 | .padding(horizontal = 16.dp, vertical = 14.dp), | |
| 194 | ) { | |
| 195 | BasicTextField( | |
| 196 | value = text, | |
| 197 | onValueChange = { text = it }, | |
| 198 | singleLine = true, | |
| 199 | textStyle = MaterialTheme.typography.titleMedium.copy(color = OnPrimary), | |
| 200 | cursorBrush = SolidColor(AccentAmber), | |
| 201 | interactionSource = interaction, | |
| 202 | visualTransformation = if (plainInput) | |
| 203 | androidx.compose.ui.text.input.VisualTransformation.None | |
| 204 | else digitTransform, | |
| 205 | keyboardOptions = KeyboardOptions( | |
| 206 | imeAction = ImeAction.Done, | |
| 207 | autoCorrect = false, | |
| 208 | ), | |
| 209 | keyboardActions = KeyboardActions( | |
| 210 | // Maintainer, 2026-10-29: shared confirm (allowEmpty-aware). | |
| 211 | onDone = { confirm() }, | |
| 212 | ), | |
| 213 | modifier = Modifier | |
| 214 | .fillMaxWidth() | |
| 215 | .focusRequester(fieldFocus), | |
| 216 | ) | |
| 217 | if (text.isEmpty()) { | |
| 218 | Text( | |
| 219 | placeholder, | |
| 220 | style = MaterialTheme.typography.titleMedium, | |
| 221 | color = OnQuiet, | |
| 222 | maxLines = 1, | |
| 223 | overflow = TextOverflow.Ellipsis, | |
| 224 | ) | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | // Maintainer, 2026-10-29: visible Cancel / confirm buttons — the IME | |
| 229 | // Done key and Back were the only exits. | |
| 230 | Spacer(Modifier.height(16.dp)) | |
| 231 | Row( | |
| 232 | Modifier.fillMaxWidth(), | |
| 233 | horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), | |
| 234 | ) { | |
| 235 | DialogButton(label = "Cancel", onClick = onDismiss) | |
| 236 | DialogButton( | |
| 237 | label = confirmLabel.replaceFirstChar { it.uppercaseChar() }, | |
| 238 | onClick = confirm, | |
| 239 | ) | |
| 240 | } | |
| 241 | ||
| 242 | Spacer(Modifier.height(12.dp)) | |
| 243 | Text( | |
| 244 | "Done to $confirmLabel · Back to cancel", | |
| 245 | style = MaterialTheme.typography.labelMedium, | |
| 246 | color = OnQuiet, | |
| 247 | ) | |
| 248 | } | |
| 249 | } | |
| 250 | } | |
| 251 | ||
| 252 | /** Maintainer, 2026-10-29: small dialog button, built like CastPickerSheet's | |
| 253 | * CloseButton. SurfaceHover (not SurfaceRaised) because the dialog panel | |
| 254 | * itself is SurfaceRaised — a SurfaceRaised button would be invisible | |
| 255 | * until focused. */ | |
| 256 | @Composable | |
| 257 | private fun DialogButton(label: String, onClick: () -> Unit) { | |
| 258 | val interaction = remember { MutableInteractionSource() } | |
| 259 | Box( | |
| 260 | Modifier | |
| 261 | .background(SurfaceHover) | |
| 262 | .staveFocusable(interaction, cornerRadiusDp = 6) | |
| 263 | .clickable(interaction, indication = null) { onClick() } | |
| 264 | .padding(horizontal = 16.dp, vertical = 10.dp), | |
| 265 | ) { | |
| 266 | Text( | |
| 267 | label, | |
| 268 | style = MaterialTheme.typography.titleMedium, | |
| 269 | color = OnPrimary, | |
| 270 | ) | |
| 271 | } | |
| 272 | } | |
| 273 | ||
| 274 | /** | |
| 275 | * Build an [AnnotatedString] from [text] where every digit run is styled with | |
| 276 | * [interFamily] and every non-digit run inherits the parent style. Length and | |
| 277 | * character positions are preserved, so this is safe to use as the rendered | |
| 278 | * text inside a [TransformedText] paired with [OffsetMapping.Identity]. | |
| 279 | * | |
| 280 | * Lives next to [SystemImeTextDialog] because the [BasicTextField] inside it is | |
| 281 | * the only place we need an editable digit-aware style; for static Text use | |
| 282 | * [withInterDigits] in Type.kt instead. | |
| 283 | */ | |
| 284 | private fun buildInterDigitSpans( | |
| 285 | text: AnnotatedString, | |
| 286 | interFamily: androidx.compose.ui.text.font.FontFamily, | |
| 287 | ): AnnotatedString = buildAnnotatedString { | |
| 288 | val raw = text.text | |
| 289 | if (raw.isEmpty()) return@buildAnnotatedString | |
| 290 | var cursor = 0 | |
| 291 | while (cursor < raw.length) { | |
| 292 | val ch = raw[cursor] | |
| 293 | if (ch in '0'..'9') { | |
| 294 | val end = (cursor until raw.length).firstOrNull { raw[it] !in '0'..'9' } ?: raw.length | |
| 295 | withStyle(SpanStyle(fontFamily = interFamily)) { append(raw.substring(cursor, end)) } | |
| 296 | cursor = end | |
| 297 | } else { | |
| 298 | val end = (cursor until raw.length).firstOrNull { raw[it] in '0'..'9' } ?: raw.length | |
| 299 | append(raw.substring(cursor, end)) | |
| 300 | cursor = end | |
| 301 | } | |
| 302 | } | |
| 303 | } |


