package com.example.aiapp import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.dp /** * A text field whose label is a line above it rather than a thing floating inside it. * * Every field in this app goes through here, and the reason is vertical space. Material's outlined * field reserves room for a label that animates into its own border and pads the value by half a * line top and bottom, so one setting costs the height of three lines of text to hold one. A form * of ten settings is then a screen and a half of scrolling to read ten short answers. * * What is *not* shrunk is the value itself: it stays at body size, because what is expensive here * is the framing rather than the text, and a field whose contents are smaller than the text beside * it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit. * * [hint] is what leaving it blank means, drawn inside the empty box. It had a grey line of its own * above the box until 2026-09-21: a form of a dozen settings was then mostly explanation, and a * setting should be a title and a box to type in. Inside, it costs no height and is gone the moment * anybody types -- which is the trade, since that is also when somebody might look back at it. */ @Composable fun LabelledField( label: String, value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, hint: String? = null, enabled: Boolean = true, /** * How many lines the box is, at rest. One for a value; several for prose, where the reader is * writing rather than filling in -- see `ParamKind::Prose`. */ lines: Int = 1, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, /** What the keyboard's own action key does, which is usually what the button beside it does. */ keyboardActions: KeyboardActions = KeyboardActions.Default, ) { Column(modifier.fillMaxWidth()) { // Body size in the ordinary text colour, which is what a setting's label is where the // control beside it is a switch or a picker. Smaller and greyer on the ones that are // fields reads as two ranks of setting where there is one. Text(label, modifier = Modifier.padding(bottom = 2.dp)) FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions, hint) } } /** The box itself: the border, the padding, and the text. Shared so the two fields agree. */ @Composable private fun FieldBox( value: String, onValueChange: (String) -> Unit, enabled: Boolean, lines: Int, keyboardOptions: KeyboardOptions, keyboardActions: KeyboardActions, hint: String?, ) { val interactions = remember { MutableInteractionSource() } val focused by interactions.collectIsFocusedAsState() // The focused border is the accent at the same width as the resting one. Growing it instead // would move the text inside by a pixel on every focus, which is a whole form twitching as the // reader moves down it. val edge = when { !enabled -> MaterialTheme.colorScheme.outlineVariant focused -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.outline } val shape = RoundedCornerShape(8.dp) val style = LocalTextStyle.current.merge( TextStyle( color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant ) ) BasicTextField( value = value, onValueChange = onValueChange, enabled = enabled, singleLine = lines == 1, minLines = lines, textStyle = style, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, interactionSource = interactions, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainerHighest, shape) .border(1.dp, edge, shape) .padding(horizontal = 10.dp, vertical = 8.dp), decorationBox = { field -> Box { // Under the text rather than beside it: the value is what the box is for, and a // hint that pushed it sideways would move every character as somebody typed. if (value.isEmpty() && hint != null) { Text(hint, color = MaterialTheme.colorScheme.onSurfaceVariant) } field() } }, ) }