fix(client/android): host cards in one row are the same height again
`LazyVerticalGrid` sizes a row to its tallest item but does NOT stretch the others, so anything variable inside a card shows up as cards stepping up and down within a single row. Two things varied. The new one: the profile chip. A bound host's card grew ~34dp its unbound neighbour didn't have. The chip's space is now reserved on every card in a section as soon as ANY card there carries one — so a user with no profiles still never sees the gap, and a mixed row is flush. The older one, which profiles only made more visible: the trust pill. "Online" and "Trust on first use" were laid out on one Row, so the long label wrapped to three lines INSIDE its pill and that card towered over a "Paired" one. The pills now wrap as pills (a FlowRow), one line each, in a reserved slot that a two-pill card and a one-pill card both fit inside. Both slots are `heightIn(min =)` rather than fixed, so a large accessibility font scale grows the card instead of truncating a host's trust state — and the pill slot is sized with room to spare rather than to an exact two lines, because the equal-height guarantee only holds while every card fits INSIDE the reservation. The hosts screenshot scene now orders its mocks so an unchipped card sits beside a chipped one, and a long trust label beside a short one — the two shapes that used to step. Verified there and on the emulator.
This commit is contained in:
@@ -641,6 +641,10 @@ fun ConnectScreen(
|
||||
val savedCards = savedHosts.flatMap { kh ->
|
||||
listOf(HostCardEntry(kh, null)) + profileStore.pinsFor(kh).map { HostCardEntry(kh, it) }
|
||||
}
|
||||
// Cards in one grid row must be the same height (the grid won't stretch them), so as soon as
|
||||
// ANY saved card carries a profile chip, they all reserve its space. Nobody who doesn't use
|
||||
// profiles ever sees the gap.
|
||||
val anyProfileChip = savedCards.any { it.pin != null || it.host.profileId != null }
|
||||
|
||||
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
|
||||
//
|
||||
@@ -966,6 +970,7 @@ fun ConnectScreen(
|
||||
profileProminent = pin != null,
|
||||
accent = accentColor(pin?.accent ?: bound?.accent),
|
||||
menuItems = hostMenu(kh, pin),
|
||||
reserveProfileSlot = anyProfileChip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -72,6 +75,7 @@ data class HostMenuItem(
|
||||
* card** ([profileProminent]) the host name is still the title, but the profile is the loud part,
|
||||
* because the pin exists to make that one combination a single tap.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun HostCard(
|
||||
name: String,
|
||||
@@ -87,6 +91,14 @@ fun HostCard(
|
||||
profileProminent: Boolean = false,
|
||||
accent: Color? = null,
|
||||
menuItems: List<HostMenuItem> = emptyList(),
|
||||
/**
|
||||
* Keep the profile chip's space even on a card that has no profile. `LazyVerticalGrid` sizes a
|
||||
* row to its tallest item but does NOT stretch the others, so a card that grew a chip would
|
||||
* leave its neighbour visibly short — a row of cards stepping up and down reads as broken
|
||||
* layout. The caller passes true when ANY card in that section carries a chip, so a user with
|
||||
* no profiles never pays for the slot.
|
||||
*/
|
||||
reserveProfileSlot: Boolean = false,
|
||||
) {
|
||||
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
|
||||
// layer is too subtle on a TV across a room — draw a clear primary-colour border when focused.
|
||||
@@ -130,19 +142,31 @@ fun HostCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (profileLabel != null) {
|
||||
if (profileLabel != null || reserveProfileSlot) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Box(
|
||||
Modifier.heightIn(min = PROFILE_CHIP_SLOT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (profileLabel != null) {
|
||||
ProfileChip(profileLabel, accent, prominent = profileProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
// Fixed slot, and pills WRAP as pills rather than wrapping their own text: "Trust
|
||||
// on first use" beside "Online" doesn't fit a narrow card, and letting the label
|
||||
// run to three lines made that card tower over a "Paired" one in the same row.
|
||||
Box(Modifier.heightIn(min = PILL_SLOT), contentAlignment = Alignment.TopCenter) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
PresencePill(online)
|
||||
StatusPill(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onForget != null || onEdit != null || onWake != null || menuItems.isNotEmpty()) {
|
||||
var menu by remember { mutableStateOf(false) }
|
||||
@@ -231,6 +255,21 @@ private fun ProfileChip(label: String, accent: Color?, prominent: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved heights for the two parts of a card that would otherwise vary: the profile chip, and the
|
||||
* presence/trust pills. `LazyVerticalGrid` sizes a row to its tallest item and does NOT stretch the
|
||||
* others, so anything variable here shows up as cards stepping up and down within one row.
|
||||
*
|
||||
* `heightIn(min =)`, not a fixed height: at a large accessibility font scale the content must be
|
||||
* allowed to grow rather than clip. That is also why the pill slot is sized with room to spare
|
||||
* rather than to a measured two lines — the equal-height guarantee only holds while every card
|
||||
* fits INSIDE the reserved space, so the reservation has to be comfortable, not exact. Past that
|
||||
* (a very large font scale) a row can step again: a rare, self-healing cosmetic cost, and the
|
||||
* right trade against truncating a host's trust state.
|
||||
*/
|
||||
private val PROFILE_CHIP_SLOT = 26.dp
|
||||
private val PILL_SLOT = 48.dp
|
||||
|
||||
/** A circular avatar with the host's first letter (Apple-contact style). */
|
||||
@Composable
|
||||
fun HostAvatar(name: String) {
|
||||
@@ -267,6 +306,7 @@ fun PresencePill(online: Boolean) {
|
||||
if (online) "Online" else "Offline",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -282,7 +322,7 @@ fun StatusPill(status: HostStatus) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color)
|
||||
Text(status.label, style = MaterialTheme.typography.labelMedium, color = color, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,16 @@ private data class MockHost(
|
||||
val accent: Color? = null,
|
||||
)
|
||||
|
||||
// Ordered so an UNCHIPPED card sits beside a CHIPPED one in the same grid row, and a long trust
|
||||
// label ("Trust on first use") beside a short one ("Paired"). Both are what used to make cards in a
|
||||
// row step up and down — the grid sizes a row to its tallest item and doesn't stretch the rest — so
|
||||
// this arrangement is the regression net for it.
|
||||
private val SAVED = listOf(
|
||||
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
|
||||
MockHost(
|
||||
"Living Room PC", "192.168.1.42:9777", HostStatus.PAIRED,
|
||||
profile = "Game", pin = "Work", accent = Color(0xFFFF8A4C),
|
||||
),
|
||||
MockHost("Office", "192.168.1.50:9777", HostStatus.TOFU),
|
||||
)
|
||||
private val DISCOVERED = listOf(
|
||||
MockHost("studio-deck", "192.168.1.61:9777", HostStatus.PAIRING),
|
||||
@@ -120,6 +124,9 @@ internal fun HostsScene() {
|
||||
HostMenuItem("Connect with: Default settings", startsSection = true) {},
|
||||
HostMenuItem("Connect with: Game") {},
|
||||
),
|
||||
// One card in this section has a chip, so every card reserves its space —
|
||||
// the shot is here to catch a row that steps.
|
||||
reserveProfileSlot = true,
|
||||
)
|
||||
}
|
||||
if (h.pin != null) {
|
||||
@@ -129,6 +136,7 @@ internal fun HostsScene() {
|
||||
onConnect = {}, onForget = null,
|
||||
profileLabel = h.pin, profileProminent = true, accent = h.accent,
|
||||
menuItems = listOf(HostMenuItem("Unpin card", startsSection = true) {}),
|
||||
reserveProfileSlot = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user