EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

SystemImeTextDialog.kt

303 lines · 12.9 KB

Changed in: 0.5.0

1package dev.stave.ui.components
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5import androidx.compose.foundation.border
6import androidx.compose.foundation.clickable
7import androidx.compose.foundation.interaction.MutableInteractionSource
8import androidx.compose.foundation.interaction.collectIsFocusedAsState
9// Maintainer, 2026-10-29: Cancel/confirm button row + TV focus trap.
10import androidx.compose.foundation.focusGroup
11import androidx.compose.foundation.layout.Arrangement
12import androidx.compose.foundation.layout.Row
13import androidx.compose.ui.focus.focusProperties
14import dev.stave.ui.theme.SurfaceHover
15import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.Column
17import androidx.compose.foundation.layout.Spacer
18import androidx.compose.foundation.layout.fillMaxSize
19import androidx.compose.foundation.layout.fillMaxWidth
20import androidx.compose.foundation.layout.height
21import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.widthIn
23import androidx.compose.foundation.shape.RoundedCornerShape
24import androidx.compose.foundation.text.BasicTextField
25import androidx.compose.foundation.text.KeyboardActions
26import androidx.compose.foundation.text.KeyboardOptions
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.LaunchedEffect
29import androidx.compose.runtime.getValue
30import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.remember
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.focus.FocusRequester
37import androidx.compose.ui.focus.focusRequester
38import androidx.compose.ui.graphics.Color
39import androidx.compose.ui.graphics.SolidColor
40import androidx.compose.ui.text.AnnotatedString
41import androidx.compose.ui.text.SpanStyle
42import androidx.compose.ui.text.buildAnnotatedString
43import androidx.compose.ui.text.input.ImeAction
44import androidx.compose.ui.text.input.OffsetMapping
45import androidx.compose.ui.text.input.TransformedText
46import androidx.compose.ui.text.input.VisualTransformation
47import androidx.compose.ui.text.style.TextOverflow
48import androidx.compose.ui.text.withStyle
49import androidx.compose.ui.unit.dp
50import androidx.tv.material3.MaterialTheme
51import androidx.tv.material3.Text
52import dev.stave.ui.theme.AccentAmber
53import dev.stave.ui.theme.OnMuted
54import dev.stave.ui.theme.OnPrimary
55import dev.stave.ui.theme.OnQuiet
56import dev.stave.ui.theme.SurfaceRaised
57import dev.stave.ui.theme.staveInterFamily
58import 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
82fun 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
257private 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 */
284private 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}