Coverage Summary for Class: SurferTextFieldKt (com.georgeci.moneysurfer.uikit.components)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SurferTextFieldKt |
63.6%
(7/11)
|
66.7%
(64/96)
|
97%
(98/101)
|
94.3%
(1553/1646)
|
| SurferTextFieldKt$SurferTextField$1$1 |
100%
(1/1)
|
50%
(1/2)
|
100%
(1/1)
|
22.2%
(4/18)
|
| Total |
66.7%
(8/12)
|
66.3%
(65/98)
|
97.1%
(99/102)
|
93.6%
(1557/1664)
|
package com.georgeci.moneysurfer.uikit.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import moneysurfer.uikit.generated.resources.Res
import moneysurfer.uikit.generated.resources.uikit_password_hide
import moneysurfer.uikit.generated.resources.uikit_password_show
import org.jetbrains.compose.resources.stringResource
/**
* Outlined form field for the app's data-entry screens.
*
* Three behaviours are the reason this exists as a shared component rather than a raw
* [OutlinedTextField] per screen:
*
* - **Errors are attached to the field.** Pass [errorText] and the message renders right under
* the input, with the error outline — instead of a detached line elsewhere on the form that
* leaves the user hunting for which input is wrong.
* - **The message line never moves the form.** The line under the field is always laid out, even
* with nothing to say, so an error appearing does not push the next field and the buttons down
* the screen. It is drawn here rather than through [OutlinedTextField]'s `supportingText` slot
* for the same reason the slot is not used for [helperText]: M3 indents supporting text by
* 16dp, which reads as a stray offset under a field the design aligns flush.
* - **The keyboard never covers the focused field.** While focused, the field re-requests
* [BringIntoViewRequester.bringIntoView] every time the IME inset changes, so it scrolls clear
* of the keyboard as it animates in. The host still has to be scrollable and IME-padded for
* this to have anywhere to scroll to.
*
* [helperText] is the always-on hint for the field; [errorText] replaces it while it is set.
* [fieldTestTag] tags the input; the message is tagged `"<fieldTestTag>:error"` so a UI test can
* assert it without matching on localized copy.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SurferTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
errorText: String? = null,
helperText: String? = null,
enabled: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
visualTransformation: VisualTransformation = VisualTransformation.None,
prefix: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
fieldTestTag: String? = null,
) {
val bringIntoView = remember { BringIntoViewRequester() }
var focused by remember { mutableStateOf(false) }
// Recomposes on every frame of the IME animation, which is exactly what re-triggers the
// scroll below — a single bringIntoView() on focus fires before the keyboard has taken up
// its space and lands the field back underneath it.
val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
LaunchedEffect(focused, imeBottom, errorText) {
if (focused) bringIntoView.bringIntoView()
}
Column(modifier = modifier.fillMaxWidth()) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
singleLine = true,
keyboardOptions = keyboardOptions,
visualTransformation = visualTransformation,
enabled = enabled,
isError = errorText != null,
prefix = prefix,
trailingIcon = trailingIcon,
shape = AppTheme.shapes.large,
colors = colors,
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(bringIntoView)
.onFocusChanged { focused = it.isFocused }
// The message is drawn below rather than through M3's `supportingText`, and that
// slot is also what put the text into the field's semantics. Without this a screen
// reader announces the field as invalid and never says why.
.then(errorText?.let { message -> Modifier.semantics { error(message) } } ?: Modifier)
.then(fieldTestTag?.let { Modifier.testTag(it) } ?: Modifier),
)
SurferFieldMessage(
text = errorText ?: helperText,
isError = errorText != null,
modifier = fieldTestTag?.let { Modifier.testTag("$it:error") } ?: Modifier,
)
}
}
/**
* The one-line message slot under a form field: the error, else the hint, else nothing visible.
*
* Always occupies its line — one line of `bodySmall` plus the gap above it — so a field that
* starts clean and later fails validation does not shift everything below it. The line is measured
* from the style rather than hard-coded: `bodySmall` is sized in `sp`, so at a system font scale
* above 1.0 a fixed dp reserve would be shorter than the text it has to hold and the shift would
* come back on exactly the devices that can least afford it.
*
* Public so screens with a control that is not a [SurferTextField] (a picker, a segmented row)
* can reserve the same line under it.
*/
@Composable
fun SurferFieldMessage(
text: String?,
modifier: Modifier = Modifier,
isError: Boolean = false,
) {
val lineHeight = AppTheme.typography.bodySmall.lineHeight
val reserved = with(LocalDensity.current) {
if (lineHeight.type == TextUnitType.Sp) lineHeight.toDp() else FallbackLineHeight
}
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = reserved + MessageTopPadding)
.padding(top = MessageTopPadding),
) {
if (text != null) {
Text(
text = text,
style = AppTheme.typography.bodySmall,
color = if (isError) {
AppTheme.materialColors.error
} else {
AppTheme.materialColors.onSurfaceVariant
},
)
}
}
}
private val MessageTopPadding: Dp = 4.dp
/** Only reached if the theme ever gives `bodySmall` an em-based line height. */
private val FallbackLineHeight: Dp = 16.dp
/**
* [SurferTextField] pre-wired for password entry: password keyboard, masked text, and a trailing
* reveal toggle. The reveal state is [rememberSaveable] so rotating the device does not silently
* re-mask a password the user chose to check.
*/
@Composable
fun SurferPasswordField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
errorText: String? = null,
enabled: Boolean = true,
imeAction: ImeAction = ImeAction.Done,
colors: TextFieldColors = OutlinedTextFieldDefaults.colors(),
fieldTestTag: String? = null,
) {
var revealed by rememberSaveable { mutableStateOf(false) }
SurferTextField(
value = value,
onValueChange = onValueChange,
label = label,
modifier = modifier,
errorText = errorText,
enabled = enabled,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = imeAction,
),
visualTransformation = if (revealed) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(
onClick = { revealed = !revealed },
enabled = enabled,
modifier = fieldTestTag?.let { Modifier.testTag("$it:reveal") } ?: Modifier,
) {
Icon(
imageVector = if (revealed) SurferIcons.VisibilityOff else SurferIcons.Visibility,
contentDescription = stringResource(
if (revealed) Res.string.uikit_password_hide else Res.string.uikit_password_show,
),
)
}
},
colors = colors,
fieldTestTag = fieldTestTag,
)
}