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

KeyboardTextDialog.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.ui.components
2
3import androidx.activity.compose.BackHandler
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.layout.Arrangement
8import androidx.compose.foundation.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.height
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.widthIn
16import androidx.compose.foundation.shape.RoundedCornerShape
17import androidx.compose.runtime.Composable
18import androidx.compose.runtime.getValue
19import androidx.compose.runtime.mutableStateOf
20import androidx.compose.runtime.remember
21import androidx.compose.runtime.setValue
22import androidx.compose.ui.Alignment
23import androidx.compose.ui.Modifier
24import androidx.compose.ui.draw.clip
25import androidx.compose.ui.focus.FocusRequester
26import androidx.compose.ui.graphics.Color
27import androidx.compose.ui.text.style.TextOverflow
28import androidx.compose.ui.unit.dp
29import androidx.tv.material3.MaterialTheme
30import androidx.tv.material3.Text
31import dev.stave.ui.theme.AccentAmber
32import dev.stave.ui.theme.OnMuted
33import dev.stave.ui.theme.OnPrimary
34import dev.stave.ui.theme.OnQuiet
35import dev.stave.ui.theme.SurfaceRaised
36import dev.stave.ui.theme.withInterDigits
37
38/**
39 * Modal text-input dialog using [OnScreenKeyboard].
40 *
41 * Used everywhere the app needs a single line of free text from the user
42 * (genre to exclude, playlist name, future ad-hoc inputs). Replaces the
43 * previous pattern of a [androidx.compose.foundation.text.BasicTextField]
44 * which on Android TV summons the system IME — Google TV's keyboard or
45 * the Shield's stock IME — neither of which respects our font choices,
46 * focus model, or styling.
47 *
48 * The dialog owns its text buffer locally (initialized to [initialText])
49 * and reports back to the caller only on confirm (via [onConfirm]) or
50 * dismiss (via [onDismiss]). Keystrokes route through [OnScreenKeyboard]'s
51 * onKey/onBackspace/onClear/onDone callbacks, so every code path that
52 * mutates text is in our component, not the platform IME.
53 *
54 * The Mic key is hidden (voiceAvailable = false). Voice input on a text
55 * dialog is rare; if it ever becomes a requirement, plumb a separate
56 * `onVoice` parameter through. For now keeping the surface narrow.
57 */
58@Composable
59fun KeyboardTextDialog(
60 title: String,
61 placeholder: String,
62 confirmLabel: String,
63 onConfirm: (String) -> Unit,
64 onDismiss: () -> Unit,
65 initialText: String = "",
66 /** Optional helper line shown beneath the title. Null = nothing rendered. */
67 subtitle: String? = null,
68) {
69 var text by remember { mutableStateOf(initialText) }
70 val firstKeyFocus = remember { FocusRequester() }
71
72 BackHandler(onBack = onDismiss)
73
74 // Full-screen scrim, dialog centered. Scrim absorbs clicks so the
75 // user can't accidentally dismiss by tapping outside the keyboard.
76 Box(
77 Modifier
78 .fillMaxSize()
79 .background(Color.Black.copy(alpha = 0.75f))
80 .clickable(
81 interactionSource = remember { MutableInteractionSource() },
82 indication = null,
83 onClick = { /* swallow */ },
84 ),
85 contentAlignment = Alignment.Center,
86 ) {
87 Column(
88 Modifier
89 .widthIn(min = 720.dp, max = 920.dp)
90 .clip(RoundedCornerShape(16.dp))
91 .background(SurfaceRaised)
92 .padding(horizontal = 32.dp, vertical = 28.dp),
93 horizontalAlignment = Alignment.CenterHorizontally,
94 ) {
95 Text(
96 title,
97 style = MaterialTheme.typography.titleLarge,
98 color = OnPrimary,
99 )
100 if (subtitle != null) {
101 Spacer(Modifier.height(8.dp))
102 Text(
103 withInterDigits(subtitle),
104 style = MaterialTheme.typography.bodyMedium,
105 color = OnMuted,
106 )
107 }
108 Spacer(Modifier.height(20.dp))
109
110 // Text preview row — shows what the user has typed so far. Acts
111 // as a non-focusable display: the OnScreenKeyboard is what carries
112 // focus, the text just visually reflects state.
113 Box(
114 Modifier
115 .fillMaxWidth()
116 .background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(4.dp))
117 .padding(horizontal = 16.dp, vertical = 14.dp),
118 ) {
119 if (text.isEmpty()) {
120 Text(
121 placeholder,
122 style = MaterialTheme.typography.titleMedium,
123 color = OnQuiet,
124 maxLines = 1,
125 overflow = TextOverflow.Ellipsis,
126 )
127 } else {
128 Text(
129 withInterDigits(text),
130 style = MaterialTheme.typography.titleMedium,
131 color = OnPrimary,
132 maxLines = 1,
133 overflow = TextOverflow.Ellipsis,
134 )
135 }
136 }
137
138 Spacer(Modifier.height(20.dp))
139
140 OnScreenKeyboard(
141 query = text,
142 onKey = { ch -> text += ch },
143 onBackspace = { if (text.isNotEmpty()) text = text.dropLast(1) },
144 onClear = { text = "" },
145 onDone = {
146 val trimmed = text.trim()
147 if (trimmed.isNotEmpty()) onConfirm(trimmed)
148 },
149 onVoice = { /* mic not exposed on text dialogs */ },
150 voiceAvailable = false,
151 onUpEscape = onDismiss,
152 firstKeyFocus = firstKeyFocus,
153 autoFocusOnEnter = true,
154 )
155
156 // Bottom hint — Done key on the keyboard confirms; this is just
157 // for users who haven't memorized the keyboard's affordances yet.
158 Spacer(Modifier.height(12.dp))
159 Text(
160 "Done to $confirmLabel · Back to cancel",
161 style = MaterialTheme.typography.labelMedium,
162 color = OnQuiet,
163 )
164 }
165 }
166}