fix(android): keep body metrics two-column on normal phones

This commit is contained in:
fy59 2026-09-20 15:30:06 +02:00
parent 60b08f225a
commit 0e13c3947a
2 changed files with 87 additions and 3 deletions

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -378,7 +379,7 @@ private fun BodyMetricField(
)
}
private data class BodyMetricItem(
internal data class BodyMetricItem(
val label: String,
val unit: String,
val value: String,
@ -386,10 +387,19 @@ private data class BodyMetricItem(
)
/** Two columns retain scanability; very narrow accessibility layouts stack. */
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
val twoCompactFieldsDp = 2 * 140f + 10f
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
}
@Composable
private fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val columns = if (maxWidth < 330.dp) 1 else 2
/* WHY: this 360 dp device has 312 dp after screen padding; the old
* 330 dp threshold therefore stacked fields that need only 140 dp.
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
* unless width or accessibility font scaling makes them unreadable. */
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {

View file

@ -0,0 +1,74 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class BodyMetricGridTest {
@get:Rule val compose = createComposeRule()
private val items = listOf(
BodyMetricItem("POIDS", "kg", "", {}),
BodyMetricItem("COU", "cm", "", {}),
BodyMetricItem("TOUR DE TAILLE", "cm", "", {}),
BodyMetricItem("HANCHES", "cm", "", {}),
)
@Test
fun deployedPhoneContentWidthUsesTwoColumns() {
// 1080 px / 480 dpi = 360 dp; the screen frame leaves about 312 dp.
assertEquals(2, bodyMetricColumnCount(312f, 0.9f))
}
@Test
fun genuinelyNarrowWidthStacksFields() {
assertEquals(1, bodyMetricColumnCount(280f, 1f))
}
@Test
fun largeAccessibilityTextStacksFields() {
assertEquals(1, bodyMetricColumnCount(312f, 1.5f))
}
@Test
fun deployedPhoneWidthRendersPairedFieldsWithoutClippingUnits() {
compose.setContent {
TrainlogTheme {
Column(Modifier.width(312.dp)) { BodyMetricGrid(items) }
}
}
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
assertTrue(neck.left > weight.left)
assertTrue(kotlin.math.abs(neck.top - weight.top) < 2f)
compose.onNodeWithText("kg").fetchSemanticsNode()
assertTrue(compose.onAllNodesWithText("cm", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty())
compose.onNodeWithText("TOUR DE TAILLE").fetchSemanticsNode()
}
@Test
fun trulyNarrowLayoutStacksPairedFields() {
compose.setContent {
TrainlogTheme {
Column(Modifier.width(280.dp)) { BodyMetricGrid(items.take(2)) }
}
}
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
assertTrue(neck.top > weight.top)
}
}