diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 4c22ef4e..45115253 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -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, ) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt index dea3c02f..a812df00 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/components/HostComponents.kt @@ -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 = 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,17 +142,29 @@ fun HostCard( overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) - if (profileLabel != null) { + if (profileLabel != null || reserveProfileSlot) { Spacer(Modifier.height(8.dp)) - ProfileChip(profileLabel, accent, prominent = profileProminent) + 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), - ) { - PresencePill(online) - StatusPill(status) + // 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) + } } } @@ -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) } } 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 8f38f073..3649d126 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 @@ -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, ) } }