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

KeyboardTextDialog.kt

package dev.stave.ui.components

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
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.runtime.Composable
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.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
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.withInterDigits

/**
 * Modal text-input dialog using [OnScreenKeyboard].
 *
 * Used everywhere the app needs a single line of free text from the user
 * (genre to exclude, playlist name, future ad-hoc inputs). Replaces the
 * previous pattern of a [androidx.compose.foundation.text.BasicTextField]
 * which on Android TV summons the system IME — Google TV's keyboard or
 * the Shield's stock IME — neither of which respects our font choices,
 * focus model, or styling.
 *
 * The dialog owns its text buffer locally (initialized to [initialText])
 * and reports back to the caller only on confirm (via [onConfirm]) or
 * dismiss (via [onDismiss]). Keystrokes route through [OnScreenKeyboard]'s
 * onKey/onBackspace/onClear/onDone callbacks, so every code path that
 * mutates text is in our component, not the platform IME.
 *
 * The Mic key is hidden (voiceAvailable = false). Voice input on a text
 * dialog is rare; if it ever becomes a requirement, plumb a separate
 * `onVoice` parameter through. For now keeping the surface narrow.
 */
@Composable
fun KeyboardTextDialog(
    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,
) {
    var text by remember { mutableStateOf(initialText) }
    val firstKeyFocus = remember { FocusRequester() }

    BackHandler(onBack = onDismiss)

    // Full-screen scrim, dialog centered. Scrim absorbs clicks so the
    // user can't accidentally dismiss by tapping outside the keyboard.
    Box(
        Modifier
            .fillMaxSize()
            .background(Color.Black.copy(alpha = 0.75f))
            .clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                onClick = { /* swallow */ },
            ),
        contentAlignment = Alignment.Center,
    ) {
        Column(
            Modifier
                .widthIn(min = 720.dp, max = 920.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))

            // Text preview row — shows what the user has typed so far. Acts
            // as a non-focusable display: the OnScreenKeyboard is what carries
            // focus, the text just visually reflects state.
            Box(
                Modifier
                    .fillMaxWidth()
                    .background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(4.dp))
                    .padding(horizontal = 16.dp, vertical = 14.dp),
            ) {
                if (text.isEmpty()) {
                    Text(
                        placeholder,
                        style = MaterialTheme.typography.titleMedium,
                        color = OnQuiet,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                } else {
                    Text(
                        withInterDigits(text),
                        style = MaterialTheme.typography.titleMedium,
                        color = OnPrimary,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
            }

            Spacer(Modifier.height(20.dp))

            OnScreenKeyboard(
                query = text,
                onKey = { ch -> text += ch },
                onBackspace = { if (text.isNotEmpty()) text = text.dropLast(1) },
                onClear = { text = "" },
                onDone = {
                    val trimmed = text.trim()
                    if (trimmed.isNotEmpty()) onConfirm(trimmed)
                },
                onVoice = { /* mic not exposed on text dialogs */ },
                voiceAvailable = false,
                onUpEscape = onDismiss,
                firstKeyFocus = firstKeyFocus,
                autoFocusOnEnter = true,
            )

            // Bottom hint — Done key on the keyboard confirms; this is just
            // for users who haven't memorized the keyboard's affordances yet.
            Spacer(Modifier.height(12.dp))
            Text(
                "Done to $confirmLabel · Back to cancel",
                style = MaterialTheme.typography.labelMedium,
                color = OnQuiet,
            )
        }
    }
}