From 416b783647266db7af8c6e0b300d242bc002ff55 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Sun, 2 Aug 2026 08:24:00 +0200 Subject: [PATCH] app: lay the server facts out in real columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block was rendered as one padded string, which only lines up in a monospaced font — and the monospace never took, so the values sat at ragged offsets and the point of the list was lost. Padding text to fake a table makes the layout depend on a typeface decision made elsewhere. It is a label column of fixed width and a value column that takes the rest, so the addresses align whatever the font does and a long value wraps inside its own column instead of under the labels. The capability list goes back to plain comma-separated text: hand-wrapping it at three per line was working around the same missing alignment. Placement too. The facts now sit under the "Check server" button that fetches them, rather than among the input fields, and the sentence explaining the endpoint sits directly under the URL field it describes — it had ended up orphaned between the two, reading as a comment on nothing. Co-Authored-By: Claude Opus 5 --- .../main/kotlin/app/echo_lot/app/RunStore.kt | 28 ++++--- .../kotlin/app/echo_lot/app/SettingsScreen.kt | 73 +++++++++++++------ 2 files changed, 67 insertions(+), 34 deletions(-) diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt index cf0a733..d2d26d3 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/RunStore.kt @@ -128,20 +128,24 @@ class RunStore(context: Context, private val settings: Settings) { */ private fun describeFacts(p: app.echo_lot.protocol.Profile): String { val lines = ArrayList() - lines += "${p.name} · server ${p.serverVersion}" + // "label|value" per line, laid out as real columns by the UI rather than padded with + // spaces here. Space padding only lines up in a monospaced font, which makes the layout + // depend on a typeface choice made somewhere else entirely. + fun row(label: String, value: String) = lines.add("$label|$value") + + row("server", "${p.name} · ${p.serverVersion}") for (t in p.targets) { - t.ip4?.let { lines += "IPv4 $it" } - t.ip4Alt?.let { lines += "IPv4 $it (alternate, for NAT behaviour tests)" } - t.ip6?.let { lines += "IPv6 $it" } - t.ip6Alt?.let { lines += "IPv6 $it (alternate, for NAT behaviour tests)" } - lines += "ports udp ${t.udpPort} · tcp ${t.tcpPort} · stun ${t.stunPort}" + t.ip4?.let { row("IPv4", it) } + t.ip6?.let { row("IPv6", it) } + // Marked rather than listed apart: it is the same server, and what matters is being + // able to tell which address a NAT-behaviour result came from. + t.ip4Alt?.let { row("IPv4 alt", it) } + t.ip6Alt?.let { row("IPv6 alt", it) } + row("ports", "udp ${t.udpPort} · tcp ${t.tcpPort} · stun ${t.stunPort}") } - if (p.canaryZone.isNotBlank()) lines += "DNS canary zone ${p.canaryZone}" - if (p.capabilities.isNotEmpty()) { - lines += "can measure ${p.capabilities.joinToString(", ")}" - } - val b = StringBuilder(lines.joinToString("\n")) - return b.toString().trimEnd() + if (p.canaryZone.isNotBlank()) row("dns zone", p.canaryZone) + if (p.capabilities.isNotEmpty()) row("measures", p.capabilities.joinToString(", ")) + return lines.joinToString(System.lineSeparator()) } fun enroll(link: String, deviceName: String?): String { diff --git a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt index 58485c8..57b3091 100644 --- a/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt +++ b/echolot-app/app/src/main/kotlin/app/echo_lot/app/SettingsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.rememberScrollState @@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import app.echo_lot.privacy.PrivacyLevel /** @@ -255,34 +257,15 @@ fun SettingsScreen( }, label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) - // What the server reports about itself. Read-only on purpose: these are facts to - // check, not settings to apply, and an editable box that changes nothing is worse - // than no box at all. - if (serverFacts.isNotBlank()) { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - "What this server reports", - style = MaterialTheme.typography.labelLarge, - ) - Text( - serverFacts, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - ) - } - } - } + // Directly under the field it explains. Anywhere else it reads as a stray sentence + // about some other part of the screen. if (settings.serverUrl.isNotBlank() && settings.serverUrl != settings.serverPublicUrl) { Text( "Connects to ${settings.serverUrl} — this server publishes one name and " + "points devices at another, so its pinned certificate can share a port " + "with its web interface.", style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f), ) } OutlinedTextField( @@ -311,6 +294,52 @@ fun SettingsScreen( serverStatus?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + // What the server reported, placed under the button that asks it rather than among + // the fields above: these are facts to read, not settings to apply, and an + // editable-looking box that changes nothing is worse than no box at all. + // + // Monospaced so the addresses line up under each other — column alignment is most + // of what makes a list of IPs quicker to read than prose. + if (serverFacts.isNotBlank()) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + "WHAT THIS SERVER REPORTS", + style = MaterialTheme.typography.labelSmall, + color = LocalContentColor.current.copy(alpha = 0.7f), + ) + // Real columns rather than padded text: the label column has a fixed + // width, so values line up whatever the font does, and a long value + // wraps inside its own column instead of under the labels. + for (line in serverFacts.lines()) { + val label = line.substringBefore('|') + val value = line.substringAfter('|', "") + Row(Modifier.fillMaxWidth()) { + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f), + modifier = Modifier.width(72.dp), + ) + Text( + value, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + modifier = Modifier.weight(1f), + ) + } + } + } + } + } Text( "This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " + "${app.echo_lot.protocol.Compat.PROTOCOL_VERSION}. It works with servers " +