fix(client/android): two spacing slips in the profile UI

The name field in "New profile" sat flush against its caption — the `Column`
holding them had no arrangement at all, so the explanatory line read as part
of the input.

And a profile's scope chip didn't line up with the chips beside it: the accent
dot was in `FilterChip`'s `leadingIcon` slot, which reserves an 18dp icon and
shrinks the chip's leading padding to suit. A 10dp dot in that slot left the
chip's insets visibly different from "Default settings" next to it. The dot
now rides inside the label, so every chip keeps the same padding and the gap
to the name is ours to set rather than a side effect.

Both were eyeball-only surfaces: no screenshot covered either. The name field
now has one — the dialog itself can't be captured (a focused text field inside
a Dialog window never reaches idle under Robolectric, the same trap the PIN
scene documents), so its body is extracted and the scene renders exactly that,
in both the normal and duplicate-name states. The chip row was already in the
profile-scope shot.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent b6819b80e2
commit cee39b3751
3 changed files with 74 additions and 31 deletions
@@ -8,7 +8,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -77,9 +79,19 @@ internal fun ProfileScopeChips(
FilterChip(
selected = selectedId == p.id,
onClick = { onSelect(p.id) },
label = { Text(p.name) },
leadingIcon = p.accent?.let { accent ->
{ AccentDot(accentColor(accent) ?: MaterialTheme.colorScheme.primary) }
// The accent dot rides INSIDE the label, not in the `leadingIcon` slot: that slot
// reserves an 18dp icon and shrinks the chip's leading padding to suit, so a
// profile with an accent would sit differently from one without and from
// "Default settings" beside it. In the label every chip keeps the same padding and
// the dot's spacing is ours to set.
label = {
Row(verticalAlignment = Alignment.CenterVertically) {
accentColor(p.accent)?.let { dot ->
AccentDot(dot, size = 8)
Spacer(Modifier.width(8.dp))
}
Text(p.name)
}
},
)
}
@@ -127,33 +139,7 @@ internal fun ProfileNameDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text("e.g. Game, Work, Travel") },
singleLine = true,
isError = duplicate,
)
Text(
if (duplicate) {
"A profile called “$trimmed” already exists."
} else {
"A profile starts out inheriting every default setting. Whatever you " +
"change while it's selected becomes an override; everything else " +
"keeps following the defaults."
},
style = MaterialTheme.typography.bodySmall,
color = if (duplicate) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
},
text = { ProfileNameFields(name, duplicate) { name = it } },
confirmButton = {
TextButton(
enabled = trimmed.isNotEmpty() && !duplicate,
@@ -164,6 +150,41 @@ internal fun ProfileNameDialog(
)
}
/**
* The name field and its caption. Extracted from [ProfileNameDialog] so the screenshot harness can
* render exactly these — a focused text field inside a Dialog window never reaches idle under
* Robolectric, so the dialog itself is uncapturable, and an eyeballed-only layout is how this
* shipped with the field and its caption touching.
*/
@Composable
internal fun ProfileNameFields(name: String, duplicate: Boolean, onNameChange: (String) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("Name") },
placeholder = { Text("e.g. Game, Work, Travel") },
singleLine = true,
isError = duplicate,
)
Text(
if (duplicate) {
"A profile called “${name.trim()}” already exists."
} else {
"A profile starts out inheriting every default setting. Whatever you " +
"change while it's selected becomes an override; everything else " +
"keeps following the defaults."
},
style = MaterialTheme.typography.bodySmall,
color = if (duplicate) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
/**
* Deleting a profile is not destructive to anything but the profile — a host bound to it falls
* back to the default settings and a card pinned to it disappears, neither of which is an error.
@@ -181,7 +202,7 @@ internal fun DeleteProfileDialog(
onDismissRequest = onDismiss,
title = { Text("Delete “${profile.name}”?") },
text = {
Column {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val consequences = buildList {
if (boundHosts > 0) {
add("$boundHosts ${plural(boundHosts, "host", "hosts")} will fall back to the default settings")
@@ -112,6 +112,9 @@ class ScreenshotTest {
TrustDialog()
}
@Test
fun newProfile() = shootRoot("new-profile") { NewProfileScene() }
@Test
fun speedTest() = shootScreen("speed-test") {
HostsScene()
@@ -37,6 +37,7 @@ import io.unom.punktfunk.SettingsCategory
import io.unom.punktfunk.SettingsScreen
import io.unom.punktfunk.StatsOverlay
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.ProfileNameFields
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.SettingsOverlay
import io.unom.punktfunk.SpeedTestDialog
@@ -244,6 +245,24 @@ internal fun SpeedTestScene() {
)
}
/**
* Creating a profile. Small, but it is the first thing a user meets when they reach for this
* feature — and dialogs only get a shot each because a layout slip inside one is invisible from
* every other scene (this one shipped with the field and its caption touching).
*/
@Composable
internal fun NewProfileScene() {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("New profile", style = MaterialTheme.typography.headlineSmall)
// The dialog's own body, not a rebuild of it — the spacing under test is the real one.
ProfileNameFields(name = "Travel", duplicate = false, onNameChange = {})
Text("Duplicate name", style = MaterialTheme.typography.headlineSmall)
ProfileNameFields(name = "Game", duplicate = true, onNameChange = {})
}
}
}
/** The real TOFU AlertDialog (mirrors ConnectScreen's PendingTrust.Kind.TRUST_NEW), shown over the host grid. */
@Composable
internal fun TrustDialog() {