package dev.stave.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
// Maintainer, 2026-10-29: Cancel/confirm button row + TV focus trap.
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.ui.focus.focusProperties
import dev.stave.ui.theme.SurfaceHover
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.OnQuiet
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits
/**
* Modal text-input dialog backed by a [BasicTextField] that summons the
* system IME.
*
* Replaces the prior in-house keyboard pattern. The system IME provides word
* suggestions, voice input, and IME-driven editor controls that an embedded
* keyboard composable cannot replicate (the IME runs in its own process and
* can't be skinned from inside a host app).
*
* Call signature is intentionally identical to the prior `KeyboardTextDialog`
* so the three callsites — AddGenreDialog, SavePlaylistScreen, SearchScreen —
* only need a rename.
*
* The dialog owns its text buffer locally (initialized to [initialText]) and
* reports back to the caller only on confirm or dismiss. Pressing Done /
* Search on the system IME triggers [onConfirm] with the trimmed text;
* pressing Back triggers [onDismiss]. The field auto-focuses on appearance
* so the IME pops up immediately.
*/
// Maintainer, 2026-10-29: opt-in for the focus-exit trap, as ActionSheet.
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun SystemImeTextDialog(
title: String,
placeholder: String,
confirmLabel: String,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
initialText: String = "",
/** Optional helper line shown beneath the title. Null = nothing rendered. */
subtitle: String? = null,
/** True = render the input verbatim in one uniform typeface with no
* visual transformation. Required for URLs and identifiers: the
* default digit transform sets digits in a different font than
* letters, which makes "<host>" and its slashes visually
* incoherent (maintainer report, 2026-08-29). */
plainInput: Boolean = false,
/** Maintainer, 2026-10-29: true = confirming an empty field passes "" to
* [onConfirm] (callers that treat empty as "clear"/"reset"). False =
* an empty confirm is ignored, as before. */
allowEmpty: Boolean = false,
) {
var text by remember { mutableStateOf(initialText) }
// Maintainer, 2026-10-29: one confirm path shared by the IME Done key and
// the new on-screen confirm button; honours [allowEmpty].
val confirm: () -> Unit = {
val trimmed = text.trim()
if (trimmed.isNotEmpty() || allowEmpty) onConfirm(trimmed)
}
val fieldFocus = remember { FocusRequester() }
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
// Per the app-wide "Fraunces everywhere except numerals" directive, the
// text field paints letters in the Fraunces titleMedium style but routes
// digit runs through Inter. The transformation is identity-mapped — it
// only restyles glyphs, never inserts or removes characters — so cursor
// positions, selection, and IME composition stay aligned with the
// underlying String buffer.
val interFamily = staveInterFamily()
val digitTransform = remember(interFamily) {
VisualTransformation { input ->
TransformedText(
text = buildInterDigitSpans(input, interFamily),
offsetMapping = OffsetMapping.Identity,
)
}
}
BackHandler(onBack = onDismiss)
LaunchedEffect(Unit) {
runCatching { fieldFocus.requestFocus() }
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.75f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { /* swallow scrim taps */ },
)
// Maintainer, 2026-10-29: TV focus trap (same as ActionSheet) — D-pad
// escaped to the screen behind (e.g. PlaybackSettings rows).
// Back and the Cancel button remain the exits.
.focusProperties { exit = { FocusRequester.Cancel } }
.focusGroup(),
// Top-anchored rather than vertically centered: the system IME pops
// up from the bottom of the screen when the field auto-focuses, and
// covered the lower half of a centered dialog. Top alignment with
// breathing-room padding keeps the entire dialog above the IME edge
// regardless of IME height (Gboard, Leanback, OEMs all differ).
contentAlignment = Alignment.TopCenter,
) {
Column(
Modifier
.padding(top = 64.dp)
.fillMaxWidth(0.92f)
.widthIn(max = 760.dp)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceRaised)
.padding(horizontal = 32.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
)
if (subtitle != null) {
Spacer(Modifier.height(8.dp))
Text(
withInterDigits(subtitle),
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
)
}
Spacer(Modifier.height(20.dp))
Box(
Modifier
.fillMaxWidth()
.background(
if (focused) AccentAmber.copy(alpha = 0.18f) else Color.Black.copy(alpha = 0.35f),
RoundedCornerShape(8.dp),
)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) AccentAmber else OnQuiet,
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.titleMedium.copy(color = OnPrimary),
cursorBrush = SolidColor(AccentAmber),
interactionSource = interaction,
visualTransformation = if (plainInput)
androidx.compose.ui.text.input.VisualTransformation.None
else digitTransform,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
autoCorrect = false,
),
keyboardActions = KeyboardActions(
// Maintainer, 2026-10-29: shared confirm (allowEmpty-aware).
onDone = { confirm() },
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(fieldFocus),
)
if (text.isEmpty()) {
Text(
placeholder,
style = MaterialTheme.typography.titleMedium,
color = OnQuiet,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Maintainer, 2026-10-29: visible Cancel / confirm buttons — the IME
// Done key and Back were the only exits.
Spacer(Modifier.height(16.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
) {
DialogButton(label = "Cancel", onClick = onDismiss)
DialogButton(
label = confirmLabel.replaceFirstChar { it.uppercaseChar() },
onClick = confirm,
)
}
Spacer(Modifier.height(12.dp))
Text(
"Done to $confirmLabel · Back to cancel",
style = MaterialTheme.typography.labelMedium,
color = OnQuiet,
)
}
}
}
/** Maintainer, 2026-10-29: small dialog button, built like CastPickerSheet's
* CloseButton. SurfaceHover (not SurfaceRaised) because the dialog panel
* itself is SurfaceRaised — a SurfaceRaised button would be invisible
* until focused. */
@Composable
private fun DialogButton(label: String, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
Box(
Modifier
.background(SurfaceHover)
.staveFocusable(interaction, cornerRadiusDp = 6)
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Text(
label,
style = MaterialTheme.typography.titleMedium,
color = OnPrimary,
)
}
}
/**
* Build an [AnnotatedString] from [text] where every digit run is styled with
* [interFamily] and every non-digit run inherits the parent style. Length and
* character positions are preserved, so this is safe to use as the rendered
* text inside a [TransformedText] paired with [OffsetMapping.Identity].
*
* Lives next to [SystemImeTextDialog] because the [BasicTextField] inside it is
* the only place we need an editable digit-aware style; for static Text use
* [withInterDigits] in Type.kt instead.
*/
private fun buildInterDigitSpans(
text: AnnotatedString,
interFamily: androidx.compose.ui.text.font.FontFamily,
): AnnotatedString = buildAnnotatedString {
val raw = text.text
if (raw.isEmpty()) return@buildAnnotatedString
var cursor = 0
while (cursor < raw.length) {
val ch = raw[cursor]
if (ch in '0'..'9') {
val end = (cursor until raw.length).firstOrNull { raw[it] !in '0'..'9' } ?: raw.length
withStyle(SpanStyle(fontFamily = interFamily)) { append(raw.substring(cursor, end)) }
cursor = end
} else {
val end = (cursor until raw.length).firstOrNull { raw[it] in '0'..'9' } ?: raw.length
append(raw.substring(cursor, end))
cursor = end
}
}
}