diff --git a/core/designsystem/src/commonMain/kotlin/io/github/droidkaigi/confsched/designsystem/component/AutoSizeText.kt b/core/designsystem/src/commonMain/kotlin/io/github/droidkaigi/confsched/designsystem/component/AutoSizeText.kt new file mode 100644 index 000000000..20d773e33 --- /dev/null +++ b/core/designsystem/src/commonMain/kotlin/io/github/droidkaigi/confsched/designsystem/component/AutoSizeText.kt @@ -0,0 +1,127 @@ +package io.github.droidkaigi.confsched.designsystem.component + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import kotlin.math.ceil + +/** + * A [Text] component that automatically downsizes its font size to fit its content, + * with the upper bound being defined by the font size of its [style]. + */ +@Composable +fun AutoSizeText( + text: String, + modifier: Modifier = Modifier, + textAlign: TextAlign = TextAlign.Unspecified, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current, + color: Color = Color.Unspecified, +) { + BoxWithConstraints( + modifier = modifier.semantics(mergeDescendants = true) {}, + contentAlignment = when (textAlign) { + TextAlign.Left, TextAlign.Start -> Alignment.CenterStart + TextAlign.Center -> Alignment.Center + TextAlign.Right, TextAlign.End -> Alignment.CenterEnd + TextAlign.Justify -> Alignment.Center + else -> Alignment.CenterStart + }, + ) { + Text( + text = text, + color = color, + fontSize = rememberFontSize(text, style, color, textAlign, maxLines), + style = style, + maxLines = maxLines, + textAlign = textAlign, + ) + } +} + +@Composable +private fun BoxWithConstraintsScope.rememberFontSize( + text: String, + style: TextStyle, + color: Color, + textAlign: TextAlign, + maxLines: Int, +): TextUnit { + val density = LocalDensity.current + val fontFamilyResolver = LocalFontFamilyResolver.current + + return remember(text, style.fontSize, textAlign, maxLines, density, maxWidth, maxHeight) { + // Helper function to calculate if a given text size + // would cause an overflow when placed into this BoxWithConstraints + val hasOverflowWhenPlaced: TextUnit.() -> Boolean = { + val finalStyle = style.merge( + TextStyle( + color = color, + fontSize = this, + textAlign = textAlign, + ), + ) + + with(density) { + Paragraph( + text = text, + style = finalStyle, + maxLines = maxLines, + constraints = Constraints(maxWidth = ceil(maxWidth.toPx()).toInt()), + density = this, + fontFamilyResolver = fontFamilyResolver, + ).run { + didExceedMaxLines || maxHeight < height.toDp() || maxWidth < minIntrinsicWidth.toDp() + } + } + } + + // If the original text size fits already without overflowing, + // then there is no need to do anything + if (!style.fontSize.hasOverflowWhenPlaced()) { + return@remember style.fontSize + } + + // Otherwise, find the biggest font size that still fits using binary search + var lo = 1 + var hi = style.fontSize.value.toInt() + val type = style.fontSize.type + + while (lo <= hi) { + val mid = lo + (hi - lo) / 2 + if (mid.asTextUnit(type).hasOverflowWhenPlaced()) { + hi = mid - 1 + } else { + lo = mid + 1 + } + } + + // After the binary search, the right pointer is the largest size + // that still works without overflowing the box + hi.asTextUnit(type) + } +} + +private fun Int.asTextUnit(type: TextUnitType) = when (type) { + TextUnitType.Sp -> this.sp + TextUnitType.Em -> this.em + TextUnitType.Unspecified -> TextUnit.Unspecified + else -> error("Invalid TextUnitType: $type") +} diff --git a/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedLargeTopAppBar.kt b/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedLargeTopAppBar.kt index fd5271c20..55db9a3d1 100644 --- a/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedLargeTopAppBar.kt +++ b/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedLargeTopAppBar.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -29,6 +28,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -68,7 +68,7 @@ fun AnimatedLargeTopAppBar( // No animation required as it is erased with alpha exit = ExitTransition.None, ) { - Text( + AutoSizeText( text = title, modifier = Modifier.then( when (isCenterTitle) { @@ -82,6 +82,7 @@ fun AnimatedLargeTopAppBar( }, ), textAlign = TextAlign.Center, + maxLines = 1, ) } }, diff --git a/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedMediumTopAppBar.kt b/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedMediumTopAppBar.kt index 86635b829..6c6080814 100644 --- a/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedMediumTopAppBar.kt +++ b/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedMediumTopAppBar.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior @@ -29,6 +28,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -68,7 +68,7 @@ fun AnimatedMediumTopAppBar( // No animation required as it is erased with alpha exit = ExitTransition.None, ) { - Text( + AutoSizeText( text = title, modifier = Modifier.then( when (isCenterTitle) { @@ -77,11 +77,13 @@ fun AnimatedMediumTopAppBar( .padding(end = navigationIconWidthDp.dp) .fillMaxWidth() } + false -> Modifier null -> Modifier.alpha(0f) }, ), textAlign = TextAlign.Center, + maxLines = 1, ) } }, diff --git a/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedTextTopAppBar.kt b/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedTextTopAppBar.kt index 6075e6e1b..1c781edf4 100644 --- a/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedTextTopAppBar.kt +++ b/core/droidkaigiui/src/commonMain/kotlin/io/github/droidkaigi/confsched/droidkaigiui/component/AnimatedTextTopAppBar.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults @@ -17,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextAlign +import io.github.droidkaigi.confsched.designsystem.component.AutoSizeText @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -38,7 +38,7 @@ fun AnimatedTextTopAppBar( TopAppBar( title = { Box(modifier = Modifier.fillMaxWidth()) { - Text( + AutoSizeText( text = title, color = textColor, modifier = Modifier @@ -47,10 +47,11 @@ fun AnimatedTextTopAppBar( alpha = 1f - transitionFraction }, textAlign = TextAlign.Start, + maxLines = 1, style = MaterialTheme.typography.headlineSmall, ) - Text( + AutoSizeText( text = title, color = textColor, modifier = Modifier @@ -60,6 +61,7 @@ fun AnimatedTextTopAppBar( alpha = transitionFraction }, textAlign = TextAlign.Center, + maxLines = 1, style = MaterialTheme.typography.titleMedium, ) } diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt index 80da65f33..ac886e908 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/robot/TimetableScreenRobot.kt @@ -27,11 +27,13 @@ import io.github.droidkaigi.confsched.model.DroidKaigi2024Day import io.github.droidkaigi.confsched.model.TimetableItem import io.github.droidkaigi.confsched.sessions.TimetableScreen import io.github.droidkaigi.confsched.sessions.TimetableScreenTestTag +import io.github.droidkaigi.confsched.sessions.TimetableTitleTestTag import io.github.droidkaigi.confsched.sessions.TimetableUiTypeChangeButtonTestTag import io.github.droidkaigi.confsched.sessions.component.TimetableGridItemTestTag import io.github.droidkaigi.confsched.sessions.section.TimetableGridTestTag import io.github.droidkaigi.confsched.sessions.section.TimetableListTestTag import io.github.droidkaigi.confsched.sessions.section.TimetableTabTestTag +import io.github.droidkaigi.confsched.testing.utils.assertLineCount import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant @@ -41,9 +43,11 @@ class TimetableScreenRobot @Inject constructor( private val screenRobot: DefaultScreenRobot, private val timetableServerRobot: DefaultTimetableServerRobot, private val deviceSetupRobot: DefaultDeviceSetupRobot, + fontScaleRobot: DefaultFontScaleRobot, ) : ScreenRobot by screenRobot, TimetableServerRobot by timetableServerRobot, - DeviceSetupRobot by deviceSetupRobot { + DeviceSetupRobot by deviceSetupRobot, + FontScaleRobot by fontScaleRobot { val clickedItems = mutableSetOf() fun setupTimetableScreenContent(customTime: LocalDateTime? = null) { @@ -68,6 +72,12 @@ class TimetableScreenRobot @Inject constructor( waitUntilIdle() } + fun checkTitleDisplayedInSingleLine() { + composeTestRule + .onNode(hasTestTag(TimetableTitleTestTag)) + .assertLineCount(1) + } + fun clickFirstSession() { composeTestRule .onAllNodes(hasTestTag(TimetableItemCardTestTag)) diff --git a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/utils/Matchers.kt b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/utils/Matchers.kt index 5d612613c..3ad7ceb7a 100644 --- a/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/utils/Matchers.kt +++ b/core/testing/src/main/java/io/github/droidkaigi/confsched/testing/utils/Matchers.kt @@ -1,12 +1,15 @@ package io.github.droidkaigi.confsched.testing.utils +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionCollection +import androidx.compose.ui.text.TextLayoutResult import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue fun hasTestTag( testTag: String, @@ -66,6 +69,22 @@ fun SemanticsNodeInteraction.assertTextDoesNotContain( } } +fun SemanticsNodeInteraction.assertLineCount(expectedCount: Int) { + fetchSemanticsNode() + .let { node -> + val results = mutableListOf() + node.config.getOrNull(SemanticsActions.GetTextLayoutResult) + ?.action + ?.invoke(results) + val result = results.firstOrNull() + + assertTrue( + "Node has unexpected line count (expected $expectedCount, but was ${result?.lineCount})", + result?.lineCount == expectedCount, + ) + } +} + private fun buildErrorMessageForMinimumCountMismatch( errorMessage: String, foundNodes: List, diff --git a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt index 1beee51f1..bcc315cec 100644 --- a/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt +++ b/feature/sessions/src/androidUnitTest/kotlin/io/github/droidkaigi/confsched/sessions/TimetableScreenTest.kt @@ -128,6 +128,18 @@ class TimetableScreenTest(private val testCase: DescribedBehavior Unit, @@ -158,12 +158,11 @@ private fun TimetableScreen( Row( verticalAlignment = Alignment.CenterVertically, ) { - Text( + AutoSizeText( + modifier = Modifier.testTag(TimetableTitleTestTag).weight(1f), text = stringResource(SessionsRes.string.timetable), - fontSize = 24.sp, - lineHeight = 32.sp, - fontWeight = FontWeight.W400, - modifier = Modifier.weight(1F), + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, ) IconButton( onClick = dropUnlessResumed(block = onSearchClick),