diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt index a076fa84..3ad8a869 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ProfileUi.kt @@ -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") diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt index af300b58..3fe894ea 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ScreenshotTest.kt @@ -112,6 +112,9 @@ class ScreenshotTest { TrustDialog() } + @Test + fun newProfile() = shootRoot("new-profile") { NewProfileScene() } + @Test fun speedTest() = shootScreen("speed-test") { HostsScene() diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt index 822c9f3a..ec633a1e 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/screenshots/ShotScenes.kt @@ -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() {