diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt index 8227b538d..cb2377578 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/component/TimetableGridItem.kt @@ -24,9 +24,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import io.github.droidkaigi.confsched2023.designsystem.preview.MultiLanguagePreviews import io.github.droidkaigi.confsched2023.designsystem.preview.MultiThemePreviews import io.github.droidkaigi.confsched2023.designsystem.theme.KaigiTheme @@ -38,12 +43,18 @@ import io.github.droidkaigi.confsched2023.model.RoomIndex.Room4 import io.github.droidkaigi.confsched2023.model.RoomIndex.Room5 import io.github.droidkaigi.confsched2023.model.TimetableItem import io.github.droidkaigi.confsched2023.model.TimetableItem.Session +import io.github.droidkaigi.confsched2023.model.TimetableSpeaker import io.github.droidkaigi.confsched2023.model.fake import io.github.droidkaigi.confsched2023.model.type import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.ScheduleIcon import io.github.droidkaigi.confsched2023.sessions.SessionsStrings.UserIcon +import io.github.droidkaigi.confsched2023.sessions.section.TimetableSizes import io.github.droidkaigi.confsched2023.ui.previewOverride import io.github.droidkaigi.confsched2023.ui.rememberAsyncImagePainter +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus +import kotlin.math.ceil +import kotlin.math.roundToInt const val TimetableGridItemTestTag = "TimetableGridItem" @@ -51,8 +62,13 @@ const val TimetableGridItemTestTag = "TimetableGridItem" fun TimetableGridItem( timetableItem: TimetableItem, onTimetableItemClick: (TimetableItem) -> Unit, + gridItemHeightPx: Int, modifier: Modifier = Modifier, ) { + val localDensity = LocalDensity.current + + val speaker = timetableItem.speakers.firstOrNull() + val hallColor = hallColors() val backgroundColor = when (timetableItem.room.type) { Room1 -> hallColor.hallA @@ -62,9 +78,26 @@ fun TimetableGridItem( Room5 -> hallColor.hallE else -> Color.White } - Box(modifier.testTag(TimetableGridItemTestTag)) { - val speaker = timetableItem.speakers.firstOrNull() + val textColor = if (speaker != null) { + hallColor.hallText + } else { + hallColor.hallTextWhenWithoutSpeakers + } + val height = with(localDensity) { gridItemHeightPx.toDp() } + val titleTextStyle = MaterialTheme.typography.labelLarge.let { + check(it.fontSize.isSp) + val (titleFontSize, titleLineHeight) = calculateFontSizeAndLineHeight( + textStyle = it, + localDensity = localDensity, + gridItemHeightPx = gridItemHeightPx, + speaker = speaker, + titleLength = timetableItem.title.currentLangTitle.length, + ) + it.copy(fontSize = titleFontSize, lineHeight = titleLineHeight, color = textColor) + } + + Box(modifier.testTag(TimetableGridItemTestTag)) { Box( modifier = Modifier .testTag(TimetableGridItemTestTag) @@ -76,25 +109,20 @@ fun TimetableGridItem( }, shape = RoundedCornerShape(4.dp), ) - .width(192.dp) + .width(TimetableGridItemSizes.width) + .height(height) .clickable { onTimetableItemClick(timetableItem) } - .padding(12.dp), + .padding(TimetableGridItemSizes.padding), ) { Column { - val textColor = if (speaker != null) { - hallColor.hallText - } else { - hallColor.hallTextWhenWithoutSpeakers - } Text( text = timetableItem.title.currentLangTitle, - style = MaterialTheme.typography.labelLarge, - color = textColor, + style = titleTextStyle, ) - Spacer(modifier = Modifier.height(4.dp)) - Row(modifier = Modifier.height(16.dp)) { + Spacer(modifier = Modifier.height(TimetableGridItemSizes.titleToScheduleSpaceHeight)) + Row(modifier = Modifier.height(TimetableGridItemSizes.scheduleHeight)) { Icon( imageVector = Icons.Default.Schedule, tint = if (speaker != null) { @@ -112,12 +140,12 @@ fun TimetableGridItem( ) } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(TimetableGridItemSizes.scheduleToSpeakerSpaceHeight)) // TODO: Dealing with more than one speaker if (speaker != null) { Row( - modifier = Modifier.height(32.dp), + modifier = Modifier.height(TimetableGridItemSizes.speakerHeight), verticalAlignment = Alignment.CenterVertically, ) { Image( @@ -140,6 +168,144 @@ fun TimetableGridItem( } } +/** + * + * Calculate the font size and line height of the title by the height of the session grid item. + * + * @param textStyle session title text style. + * @param localDensity local density. + * @param gridItemHeightPx session grid item height. (unit is px.) + * @param speaker session speaker. + * @param titleLength session title length. + * + * @return calculated font size and line height. (Both units are sp.) + * + */ +private fun calculateFontSizeAndLineHeight( + textStyle: TextStyle, + localDensity: Density, + gridItemHeightPx: Int, + speaker: TimetableSpeaker?, + titleLength: Int, +): Pair { + // The height of the title that should be displayed. + val titleToScheduleSpaceHeightPx = with(localDensity) { + TimetableGridItemSizes.titleToScheduleSpaceHeight.toPx() + } + val scheduleHeightPx = with(localDensity) { + TimetableGridItemSizes.scheduleHeight.toPx() + } + val scheduleToSpeakerSpaceHeightPx = with(localDensity) { + TimetableGridItemSizes.scheduleToSpeakerSpaceHeight.toPx() + } + val horizontalPaddingPx = with(localDensity) { + (TimetableGridItemSizes.padding * 2).toPx() + } + var displayTitleHeight = + gridItemHeightPx - titleToScheduleSpaceHeightPx - scheduleHeightPx - scheduleToSpeakerSpaceHeightPx - horizontalPaddingPx + displayTitleHeight -= if (speaker != null) { + with(localDensity) { TimetableGridItemSizes.speakerHeight.toPx() } + } else { + 0f + } + + // Actual height of displayed title. + val boxWidthWithoutPadding = with(localDensity) { + (TimetableGridItemSizes.width - TimetableGridItemSizes.padding * 2).toPx() + } + val fontSizePx = with(localDensity) { textStyle.fontSize.toPx() } + val lineHeightPx = with(localDensity) { textStyle.lineHeight.toPx() } + var actualTitleHeight = calculateTitleHeight( + fontSizePx = fontSizePx, + lineHeightPx = lineHeightPx, + titleLength = titleLength, + maxWidth = boxWidthWithoutPadding, + ) + + return when { + displayTitleHeight <= 0 -> + Pair(TimetableGridItemSizes.minTitleFontSize, TimetableGridItemSizes.minTitleLineHeight) + displayTitleHeight > actualTitleHeight -> + Pair(textStyle.fontSize, textStyle.lineHeight) + else -> { + // Change the font size until it fits in the height of the title box. + var fontResizePx = fontSizePx + var lineHeightResizePx = lineHeightPx + + val minFontSizePx = with(localDensity) { + TimetableGridItemSizes.minTitleFontSize.toPx() + } + val middleLineHeightPx = with(localDensity) { + TimetableGridItemSizes.middleTitleLineHeight.toPx() + } + val minLineHeightPx = with(localDensity) { + TimetableGridItemSizes.minTitleLineHeight.toPx() + } + + while (displayTitleHeight <= actualTitleHeight) { + if (fontResizePx <= minFontSizePx) { + fontResizePx = minFontSizePx + lineHeightResizePx = minLineHeightPx + break + } + + fontResizePx -= with(localDensity) { 1.sp.toPx() } + val fontResize = with(localDensity) { fontResizePx.toSp() } + if (fontResize <= 12.sp && fontResize > 10.sp) { + lineHeightResizePx = middleLineHeightPx + } else if (fontResize <= 10.sp) { + lineHeightResizePx = minLineHeightPx + } + actualTitleHeight = calculateTitleHeight( + fontSizePx = fontResizePx, + lineHeightPx = lineHeightResizePx, + titleLength = titleLength, + maxWidth = boxWidthWithoutPadding, + ) + } + + Pair( + with(localDensity) { fontResizePx.toSp() }, + with(localDensity) { lineHeightResizePx.toSp() }, + ) + } + } +} + +/** + * + * Calculate the title height. + * + * @param fontSizePx font size. (unit is px.) + * @param lineHeightPx line height. (unit is px.) + * @param titleLength session title length. + * @param maxWidth max width of session grid item. + * + * @return calculated title height. (unit is px.) + * + */ +private fun calculateTitleHeight( + fontSizePx: Float, + lineHeightPx: Float, + titleLength: Int, + maxWidth: Float, +): Float { + val rows = ceil(titleLength * fontSizePx / maxWidth) + return fontSizePx + (lineHeightPx * (rows - 1f)) +} + +object TimetableGridItemSizes { + val width = 192.dp + val padding = 12.dp + val titleToScheduleSpaceHeight = 4.dp + val scheduleHeight = 16.dp + val scheduleToSpeakerSpaceHeight = 16.dp + val speakerHeight = 32.dp + val minTitleFontSize = 10.sp + val middleTitleLineHeight = 16.sp // base on MaterialTheme.typography.labelSmall.lineHeight + val minTitleLineHeight = 12.sp +} + @MultiLanguagePreviews @Composable fun PreviewTimetableGridItem() { @@ -148,6 +314,36 @@ fun PreviewTimetableGridItem() { TimetableGridItem( timetableItem = Session.fake(), onTimetableItemClick = {}, + gridItemHeightPx = 350, + ) + } + } +} + +@MultiLanguagePreviews +@Composable +fun PreviewTimetableGridLongTitleItem() { + val fake = Session.fake() + + val localDensity = LocalDensity.current + val verticalScale = 1f + + val minutePx = with(localDensity) { TimetableSizes.minuteHeight.times(verticalScale).toPx() } + val displayEndsAt = fake.endsAt.minus(1, DateTimeUnit.MINUTE) + val height = ((displayEndsAt - fake.startsAt).inWholeMinutes * minutePx).roundToInt() + + KaigiTheme { + Surface { + TimetableGridItem( + timetableItem = Session.fake().let { + val longTitle = it.title.copy( + jaTitle = it.title.jaTitle.repeat(2), + enTitle = it.title.enTitle.repeat(2), + ) + it.copy(title = longTitle) + }, + onTimetableItemClick = {}, + gridItemHeightPx = height, ) } } @@ -163,6 +359,7 @@ internal fun PreviewTimetableGridItem( TimetableGridItem( timetableItem = timetableItem, onTimetableItemClick = {}, + gridItemHeightPx = 350, ) } } diff --git a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableGrid.kt b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableGrid.kt index 85e918e14..a24f22512 100644 --- a/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableGrid.kt +++ b/feature/sessions/src/main/java/io/github/droidkaigi/confsched2023/sessions/section/TimetableGrid.kt @@ -78,10 +78,11 @@ fun TimetableGrid( timetableState = timetableGridState, modifier = modifier, contentPadding = PaddingValues(16.dp), - ) { timetableItem -> + ) { timetableItem, itemHeightPx -> TimetableGridItem( timetableItem = timetableItem, onTimetableItemClick = onTimetableItemClick, + gridItemHeightPx = itemHeightPx, ) } } @@ -93,13 +94,9 @@ fun TimetableGrid( timetableState: TimetableState, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), - content: @Composable (TimetableItem) -> Unit, + content: @Composable (TimetableItem, Int) -> Unit, ) { val coroutineScope = rememberCoroutineScope() - val itemProvider = itemProvider({ timetable.timetableItems.size }) { index -> - val timetableItemWithFavorite = timetable.contents[index] - content(timetableItemWithFavorite.timetableItem) - } val density = timetableState.density val verticalScale = timetableState.screenScaleState.verticalScale val timetableLayout = remember(timetable, verticalScale) { @@ -118,6 +115,12 @@ fun TimetableGrid( val linePxSize = with(timetableState.density) { TimetableSizes.lineStrokeSize.toPx() } val layoutDirection = LocalLayoutDirection.current + val itemProvider = itemProvider({ timetable.timetableItems.size }) { index -> + val timetableItemWithFavorite = timetable.contents[index] + val itemHeightPx = timetableLayout.timetableLayouts[index].height + content(timetableItemWithFavorite.timetableItem, itemHeightPx) + } + LazyLayout( modifier = modifier .padding( @@ -248,10 +251,11 @@ fun TimetablePreview() { modifier = Modifier.fillMaxSize(), timetable = Timetable.fake(), timetableState = timetableState, - ) { timetableItem -> + ) { timetableItem, itemHeightPx -> TimetableGridItem( timetableItem = timetableItem, onTimetableItemClick = {}, + gridItemHeightPx = itemHeightPx, ) } }