app: lay the server facts out in real columns

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 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 08:24:00 +02:00
co-authored by Claude Opus 5
parent c4f2a10790
commit 416b783647
2 changed files with 67 additions and 34 deletions
@@ -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<String>()
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 {
@@ -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 " +