From 6727bd0430d236b511881924377f76ee7fd52cdd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Nov 2025 14:15:59 -0500 Subject: [PATCH] Improves padding of the ListItem interfaces --- .../amethyst/ui/layouts/ChatHeaderLayout.kt | 112 ++- .../ui/layouts/listItem/SlimListItemLayout.kt | 703 ++++++++++++++++++ .../amethyst/ui/note/UserCompose.kt | 35 +- .../amethyst/ui/note/ZapNoteCompose.kt | 139 +++- .../chats/rooms/ChatroomHeaderCompose.kt | 38 +- .../loggedIn/profile/zaps/LnZapFeedView.kt | 9 +- .../vitorpamplona/amethyst/ui/theme/Theme.kt | 24 +- 7 files changed, 919 insertions(+), 141 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index 204e28356..68281470f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble @@ -47,57 +48,86 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.utils.TimeUtils @Composable @Preview fun ChannelNamePreview() { - Column { - ChatHeaderLayout( - channelPicture = { - Image( - painter = painterRes(R.drawable.github, 1), - contentDescription = stringRes(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - ) - }, - firstRow = { - Text("This is my author", Modifier.weight(1f)) - TimeAgo(TimeUtils.now()) - }, - secondRow = { - Text("This is a message from this person", Modifier.weight(1f)) - NewItemsBubble() - }, - onClick = {}, - ) - - HorizontalDivider(thickness = DividerThickness) - - ListItem( - headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + ThemeComparisonColumn { + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterRes(R.drawable.github, 1), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { Text("This is my author", Modifier.weight(1f)) TimeAgo(TimeUtils.now()) - } - }, - supportingContent = { - Row { + }, + secondRow = { Text("This is a message from this person", Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) NewItemsBubble() - } - }, - leadingContent = { - Image( - painter = painterRes(R.drawable.github, 2), - contentDescription = stringRes(id = R.string.profile_banner), - contentScale = ContentScale.FillWidth, - modifier = Size55Modifier, - ) - }, - ) + }, + onClick = {}, + ) - HorizontalDivider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) + + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + + SlimListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt new file mode 100644 index 000000000..89dc89d79 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -0,0 +1,703 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.layouts.listItem + +import android.view.Surface +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.MultiContentMeasurePolicy +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout +import com.vitorpamplona.amethyst.ui.layouts.listItem.ListTokens.ListItemContainerElevation +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewItemsBubble +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.math.max + +/** + * This is a copy of Material3's ListItemLayout.kt file, with the only change being the padding change from 16.dp to 10.dp + */ + +@Composable +@Preview +fun ChannelNamePreview() { + ThemeComparisonColumn { + Column { + ChatHeaderLayout( + channelPicture = { + Image( + painter = painterRes(R.drawable.github, 1), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + ) + }, + firstRow = { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + }, + secondRow = { + Text("This is a message from this person", Modifier.weight(1f)) + NewItemsBubble() + }, + onClick = {}, + ) + + HorizontalDivider(thickness = DividerThickness) + + SlimListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + + ListItem( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is my author", Modifier.weight(1f)) + TimeAgo(TimeUtils.now()) + } + }, + supportingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("This is a message from this person", Modifier.weight(1f)) + Spacer(modifier = Height4dpModifier) + NewItemsBubble() + } + }, + leadingContent = { + Image( + painter = painterRes(R.drawable.github, 2), + contentDescription = stringRes(id = R.string.profile_banner), + contentScale = ContentScale.FillWidth, + modifier = Size55Modifier, + ) + }, + ) + + HorizontalDivider(thickness = DividerThickness) + } + } +} + +@Composable +fun SlimListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemContainerElevation, + shadowElevation: Dp = ListItemContainerElevation, +) { + val decoratedHeadlineContent: @Composable () -> Unit = { + ProvideTextStyleFromToken( + colors.headlineColor, + MaterialTheme.typography.bodyLarge, + headlineContent, + ) + } + val decoratedSupportingContent: @Composable (() -> Unit)? = + supportingContent?.let { + { + Box(SupportingContentTopPadding) { + ProvideTextStyleFromToken( + colors.supportingTextColor, + MaterialTheme.typography.bodyMedium, + it, + ) + } + } + } + val decoratedOverlineContent: @Composable (() -> Unit)? = + overlineContent?.let { + { + ProvideTextStyleFromToken( + colors.overlineColor, + MaterialTheme.typography.labelSmall, + it, + ) + } + } + val decoratedLeadingContent: @Composable (() -> Unit)? = + leadingContent?.let { + { + Box(LeadingContentEndPadding) { + CompositionLocalProvider( + LocalContentColor provides colors.leadingIconColor, + content = it, + ) + } + } + } + val decoratedTrailingContent: @Composable (() -> Unit)? = + trailingContent?.let { + { + Box(TrailingContentStartPadding) { + ProvideTextStyleFromToken( + colors.trailingIconColor, + MaterialTheme.typography.labelSmall, + content = it, + ) + } + } + } + + Surface( + modifier = Modifier.semantics(mergeDescendants = true) {}.then(modifier), + shape = ListItemDefaults.shape, + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation, + ) { + SlimListItemLayout( + headline = decoratedHeadlineContent, + overline = decoratedOverlineContent, + supporting = decoratedSupportingContent, + leading = decoratedLeadingContent, + trailing = decoratedTrailingContent, + ) + } +} + +@Composable +private fun SlimListItemLayout( + leading: @Composable (() -> Unit)?, + trailing: @Composable (() -> Unit)?, + headline: @Composable () -> Unit, + overline: @Composable (() -> Unit)?, + supporting: @Composable (() -> Unit)?, +) { + val measurePolicy = remember { ListItemMeasurePolicy() } + Layout( + contents = + listOf(headline, overline ?: {}, supporting ?: {}, leading ?: {}, trailing ?: {}), + measurePolicy = measurePolicy, + ) +} + +private class ListItemMeasurePolicy : MultiContentMeasurePolicy { + override fun MeasureScope.measure( + measurables: List>, + constraints: Constraints, + ): MeasureResult { + val ( + headlineMeasurable, + overlineMeasurable, + supportingMeasurable, + leadingMeasurable, + trailingMeasurable, + ) = + measurables + var currentTotalWidth = 0 + + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val startPadding = ListItemStartPadding + val endPadding = ListItemEndPadding + val horizontalPadding = (startPadding + endPadding).roundToPx() + + // ListItem layout has a cycle in its dependencies which we use + // intrinsic measurements to break: + // 1. Intrinsic leading/trailing width + // 2. Intrinsic supporting height + // 3. Intrinsic vertical padding + // 4. Actual leading/trailing measurement + // 5. Actual supporting measurement + // 6. Actual vertical padding + val intrinsicLeadingWidth = + leadingMeasurable.firstOrNull()?.minIntrinsicWidth(constraints.maxHeight) ?: 0 + val intrinsicTrailingWidth = + trailingMeasurable.firstOrNull()?.minIntrinsicWidth(constraints.maxHeight) ?: 0 + val intrinsicSupportingWidthConstraint = + looseConstraints.maxWidth.subtractConstraintSafely( + intrinsicLeadingWidth + intrinsicTrailingWidth + horizontalPadding, + ) + val intrinsicSupportingHeight = + supportingMeasurable + .firstOrNull() + ?.minIntrinsicHeight(intrinsicSupportingWidthConstraint) ?: 0 + val intrinsicIsSupportingMultiline = + isSupportingMultilineHeuristic(intrinsicSupportingHeight) + val intrinsicListItemType = + ListItemType( + hasOverline = overlineMeasurable.firstOrNull() != null, + hasSupporting = supportingMeasurable.firstOrNull() != null, + isSupportingMultiline = intrinsicIsSupportingMultiline, + ) + val intrinsicVerticalPadding = (verticalPadding(intrinsicListItemType) * 2).roundToPx() + + val paddedLooseConstraints = + looseConstraints.offset( + horizontal = -horizontalPadding, + vertical = -intrinsicVerticalPadding, + ) + + val leadingPlaceable = leadingMeasurable.firstOrNull()?.measure(paddedLooseConstraints) + currentTotalWidth += leadingPlaceable.widthOrZero + + val trailingPlaceable = + trailingMeasurable + .firstOrNull() + ?.measure(paddedLooseConstraints.offset(horizontal = -currentTotalWidth)) + currentTotalWidth += trailingPlaceable.widthOrZero + + var currentTotalHeight = 0 + + val headlinePlaceable = + headlineMeasurable + .firstOrNull() + ?.measure(paddedLooseConstraints.offset(horizontal = -currentTotalWidth)) + currentTotalHeight += headlinePlaceable.heightOrZero + + val supportingPlaceable = + supportingMeasurable + .firstOrNull() + ?.measure( + paddedLooseConstraints.offset( + horizontal = -currentTotalWidth, + vertical = -currentTotalHeight, + ), + ) + currentTotalHeight += supportingPlaceable.heightOrZero + val isSupportingMultiline = + supportingPlaceable != null && + (supportingPlaceable[FirstBaseline] != supportingPlaceable[LastBaseline]) + + val overlinePlaceable = + overlineMeasurable + .firstOrNull() + ?.measure( + paddedLooseConstraints.offset( + horizontal = -currentTotalWidth, + vertical = -currentTotalHeight, + ), + ) + + val listItemType = + ListItemType( + hasOverline = overlinePlaceable != null, + hasSupporting = supportingPlaceable != null, + isSupportingMultiline = isSupportingMultiline, + ) + val topPadding = verticalPadding(listItemType) + val verticalPadding = topPadding * 2 + + val width = + calculateWidth( + leadingWidth = leadingPlaceable.widthOrZero, + trailingWidth = trailingPlaceable.widthOrZero, + headlineWidth = headlinePlaceable.widthOrZero, + overlineWidth = overlinePlaceable.widthOrZero, + supportingWidth = supportingPlaceable.widthOrZero, + horizontalPadding = horizontalPadding, + constraints = constraints, + ) + val height = + calculateHeight( + leadingHeight = leadingPlaceable.heightOrZero, + trailingHeight = trailingPlaceable.heightOrZero, + headlineHeight = headlinePlaceable.heightOrZero, + overlineHeight = overlinePlaceable.heightOrZero, + supportingHeight = supportingPlaceable.heightOrZero, + listItemType = listItemType, + verticalPadding = verticalPadding.roundToPx(), + constraints = constraints, + ) + + return place( + width = width, + height = height, + leadingPlaceable = leadingPlaceable, + trailingPlaceable = trailingPlaceable, + headlinePlaceable = headlinePlaceable, + overlinePlaceable = overlinePlaceable, + supportingPlaceable = supportingPlaceable, + isThreeLine = listItemType == ListItemType.ThreeLine, + startPadding = startPadding.roundToPx(), + endPadding = endPadding.roundToPx(), + topPadding = topPadding.roundToPx(), + ) + } + + override fun IntrinsicMeasureScope.maxIntrinsicHeight( + measurables: List>, + width: Int, + ): Int = calculateIntrinsicHeight(measurables, width, IntrinsicMeasurable::maxIntrinsicHeight) + + override fun IntrinsicMeasureScope.maxIntrinsicWidth( + measurables: List>, + height: Int, + ): Int = calculateIntrinsicWidth(measurables, height, IntrinsicMeasurable::maxIntrinsicWidth) + + override fun IntrinsicMeasureScope.minIntrinsicHeight( + measurables: List>, + width: Int, + ): Int = calculateIntrinsicHeight(measurables, width, IntrinsicMeasurable::minIntrinsicHeight) + + override fun IntrinsicMeasureScope.minIntrinsicWidth( + measurables: List>, + height: Int, + ): Int = calculateIntrinsicWidth(measurables, height, IntrinsicMeasurable::minIntrinsicWidth) + + private fun IntrinsicMeasureScope.calculateIntrinsicWidth( + measurables: List>, + height: Int, + intrinsicMeasure: IntrinsicMeasurable.(height: Int) -> Int, + ): Int { + val ( + headlineMeasurable, + overlineMeasurable, + supportingMeasurable, + leadingMeasurable, + trailingMeasurable, + ) = + measurables + return calculateWidth( + leadingWidth = leadingMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + trailingWidth = trailingMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + headlineWidth = headlineMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + overlineWidth = overlineMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + supportingWidth = supportingMeasurable.firstOrNull()?.intrinsicMeasure(height) ?: 0, + horizontalPadding = (ListItemStartPadding + ListItemEndPadding).roundToPx(), + constraints = Constraints(), + ) + } + + private fun IntrinsicMeasureScope.calculateIntrinsicHeight( + measurables: List>, + width: Int, + intrinsicMeasure: IntrinsicMeasurable.(width: Int) -> Int, + ): Int { + val ( + headlineMeasurable, + overlineMeasurable, + supportingMeasurable, + leadingMeasurable, + trailingMeasurable, + ) = + measurables + + var remainingWidth = + width.subtractConstraintSafely((ListItemStartPadding + ListItemEndPadding).roundToPx()) + val leadingHeight = + leadingMeasurable.firstOrNull()?.let { + val height = it.intrinsicMeasure(remainingWidth) + remainingWidth = + remainingWidth.subtractConstraintSafely( + it.maxIntrinsicWidth(Constraints.Infinity), + ) + height + } ?: 0 + val trailingHeight = + trailingMeasurable.firstOrNull()?.let { + val height = it.intrinsicMeasure(remainingWidth) + remainingWidth = + remainingWidth.subtractConstraintSafely( + it.maxIntrinsicWidth(Constraints.Infinity), + ) + height + } ?: 0 + val overlineHeight = overlineMeasurable.firstOrNull()?.intrinsicMeasure(remainingWidth) ?: 0 + val headlineHeight = headlineMeasurable.firstOrNull()?.intrinsicMeasure(remainingWidth) ?: 0 + val supportingHeight = + supportingMeasurable.firstOrNull()?.intrinsicMeasure(remainingWidth) ?: 0 + val isSupportingMultiline = isSupportingMultilineHeuristic(supportingHeight) + val listItemType = + ListItemType( + hasOverline = overlineHeight > 0, + hasSupporting = supportingHeight > 0, + isSupportingMultiline = isSupportingMultiline, + ) + + return calculateHeight( + leadingHeight = leadingHeight, + trailingHeight = trailingHeight, + headlineHeight = headlineHeight, + overlineHeight = overlineHeight, + supportingHeight = supportingHeight, + listItemType = listItemType, + verticalPadding = (verticalPadding(listItemType) * 2).roundToPx(), + constraints = Constraints(), + ) + } +} + +private fun IntrinsicMeasureScope.calculateWidth( + leadingWidth: Int, + trailingWidth: Int, + headlineWidth: Int, + overlineWidth: Int, + supportingWidth: Int, + horizontalPadding: Int, + constraints: Constraints, +): Int { + if (constraints.hasBoundedWidth) { + return constraints.maxWidth + } + // Fallback behavior if width constraints are infinite + val mainContentWidth = maxOf(headlineWidth, overlineWidth, supportingWidth) + return horizontalPadding + leadingWidth + mainContentWidth + trailingWidth +} + +private fun IntrinsicMeasureScope.calculateHeight( + leadingHeight: Int, + trailingHeight: Int, + headlineHeight: Int, + overlineHeight: Int, + supportingHeight: Int, + listItemType: ListItemType, + verticalPadding: Int, + constraints: Constraints, +): Int { + val defaultMinHeight = + when (listItemType) { + ListItemType.OneLine -> ListTokens.ListItemOneLineContainerHeight + ListItemType.TwoLine -> ListTokens.ListItemTwoLineContainerHeight + else -> ListTokens.ListItemThreeLineContainerHeight + } + val minHeight = max(constraints.minHeight, defaultMinHeight.roundToPx()) + + val mainContentHeight = headlineHeight + overlineHeight + supportingHeight + + return max(minHeight, verticalPadding + maxOf(leadingHeight, mainContentHeight, trailingHeight)) + .coerceAtMost(constraints.maxHeight) +} + +private fun MeasureScope.place( + width: Int, + height: Int, + leadingPlaceable: Placeable?, + trailingPlaceable: Placeable?, + headlinePlaceable: Placeable?, + overlinePlaceable: Placeable?, + supportingPlaceable: Placeable?, + isThreeLine: Boolean, + startPadding: Int, + endPadding: Int, + topPadding: Int, +): MeasureResult = + layout(width, height) { + leadingPlaceable?.let { + it.placeRelative( + x = startPadding, + y = if (isThreeLine) topPadding else CenterVertically.align(it.height, height), + ) + } + + val mainContentX = startPadding + leadingPlaceable.widthOrZero + val mainContentY = + if (isThreeLine) { + topPadding + } else { + val totalHeight = + headlinePlaceable.heightOrZero + + overlinePlaceable.heightOrZero + + supportingPlaceable.heightOrZero + CenterVertically.align(totalHeight, height) + } + var currentY = mainContentY + + overlinePlaceable?.placeRelative(mainContentX, currentY) + currentY += overlinePlaceable.heightOrZero + + headlinePlaceable?.placeRelative(mainContentX, currentY) + currentY += headlinePlaceable.heightOrZero + + supportingPlaceable?.placeRelative(mainContentX, currentY) + + trailingPlaceable?.let { + it.placeRelative( + x = width - endPadding - it.width, + y = if (isThreeLine) topPadding else CenterVertically.align(it.height, height), + ) + } + } + +internal val Placeable?.widthOrZero: Int + get() = this?.width ?: 0 + +internal val Placeable?.heightOrZero: Int + get() = this?.height ?: 0 + +/** + * Subtracts one value from another, where both values represent constraints used in layout. + * + * Notably: + * - if [this] is [Constraints.Infinity], the result stays [Constraints.Infinity] + * - the result is coerced to be non-negative + */ +internal fun Int.subtractConstraintSafely(other: Int): Int { + if (this == Constraints.Infinity) { + return this + } + return (this - other).coerceAtLeast(0) +} + +// In the actual layout phase, we can query supporting baselines, +// but for an intrinsic measurement pass, we have to estimate. +private fun Density.isSupportingMultilineHeuristic(estimatedSupportingHeight: Int): Boolean = estimatedSupportingHeight > 30.sp.roundToPx() + +private fun verticalPadding(listItemType: ListItemType): Dp = + when (listItemType) { + ListItemType.ThreeLine -> ListItemThreeLineVerticalPadding + else -> ListItemVerticalPadding + } + +@JvmInline +private value class ListItemType private constructor( + private val lines: Int, +) : Comparable { + override operator fun compareTo(other: ListItemType) = lines.compareTo(other.lines) + + companion object { + /** One line list item */ + val OneLine = ListItemType(1) + + /** Two line list item */ + val TwoLine = ListItemType(2) + + /** Three line list item */ + val ThreeLine = ListItemType(3) + + internal operator fun invoke( + hasOverline: Boolean, + hasSupporting: Boolean, + isSupportingMultiline: Boolean, + ): ListItemType = + when { + (hasOverline && hasSupporting) || isSupportingMultiline -> ThreeLine + hasOverline || hasSupporting -> TwoLine + else -> OneLine + } + } +} + +@Composable +internal fun ProvideTextStyleFromToken( + contentColor: Color, + textStyle: TextStyle, + content: @Composable () -> Unit, +) { + val mergedStyle = LocalTextStyle.current.merge(textStyle) + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides mergedStyle, + content = content, + ) +} + +object ListTokens { + val ListItemContainerElevation = 0.0.dp + val ListItemOneLineContainerHeight = 56.0.dp + val ListItemThreeLineContainerHeight = 88.0.dp + val ListItemTwoLineContainerHeight = 72.0.dp +} + +// Container related defaults +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val ListItemVerticalPadding = 8.dp + +@VisibleForTesting internal val ListItemThreeLineVerticalPadding = 12.dp + +@VisibleForTesting internal val ListItemStartPadding = 10.dp + +@VisibleForTesting internal val ListItemEndPadding = 10.dp + +// Icon related defaults. +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val LeadingContentEndPadding = Modifier.padding(end = 10.dp) + +// Icon related defaults. +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val SupportingContentTopPadding = Modifier.padding(top = 2.dp) + +// Trailing related defaults. +// TODO: Make sure these values stay up to date until replaced with tokens. +@VisibleForTesting internal val TrailingContentStartPadding = Modifier.padding(start = 10.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 1f053cb2f..92e56cfe9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -45,33 +46,31 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.StdStartPadding @Composable fun UserCompose( baseUser: User, - modifier: Modifier = StdPadding, + modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, ) { - Row( + SlimListItem( modifier = modifier.clickable { nav.nav(routeFor(baseUser)) }, - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) - - Column(modifier = remember { StdStartPadding.weight(1f) }) { - Row(verticalAlignment = Alignment.CenterVertically) { - UsernameDisplay(baseUser, accountViewModel = accountViewModel) - } - + leadingContent = { + UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) + }, + headlineContent = { + UsernameDisplay(baseUser, accountViewModel = accountViewModel) + }, + supportingContent = { AboutDisplay(baseUser, accountViewModel) - } - - Row(modifier = StdStartPadding) { - UserActionOptions(baseUser, accountViewModel, nav) - } - } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserActionOptions(baseUser, accountViewModel, nav) + } + }, + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index 1258ca406..44388b352 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -24,7 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,19 +37,24 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.coroutines.Dispatchers @@ -63,11 +68,11 @@ fun ZapNoteCompose( ) { val baseNoteRequest by observeNote(baseReqResponse.zapRequest, accountViewModel) - var baseAuthor by remember { mutableStateOf(null) } + var baseAuthor by remember { mutableStateOf(baseReqResponse.zapRequest.author) } LaunchedEffect(baseNoteRequest) { - baseNoteRequest?.note?.let { - accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) { baseAuthor = it?.user } + accountViewModel.decryptAmountMessage(baseNoteRequest.note, baseReqResponse.zapEvent) { + baseAuthor = it?.user } } @@ -81,49 +86,95 @@ fun ZapNoteCompose( ), verticalArrangement = Arrangement.Center, ) { - baseAuthor?.let { RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } + baseAuthor?.let { RenderZapNoteSlim(it, baseReqResponse.zapEvent, accountViewModel, nav) } } } } +@Preview +@Composable +fun RenderZapNotePreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val note1: Note = LocalCache.getOrCreateNote("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + + ThemeComparisonColumn { + RenderZapNote( + user1, + note1, + accountViewModel, + EmptyNav, + ) + } +} + +@Preview +@Composable +fun RenderZapNoteSlimPreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val note1: Note = LocalCache.getOrCreateNote("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + + ThemeComparisonColumn { + RenderZapNoteSlim( + user1, + note1, + accountViewModel, + EmptyNav, + ) + } +} + @Composable private fun RenderZapNote( baseAuthor: User, zapNote: Note, - nav: INav, accountViewModel: AccountViewModel, + nav: INav, ) { - Row( - modifier = - remember { - Modifier.padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - ) - }, - verticalAlignment = Alignment.CenterVertically, - ) { - UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) - - Column( - modifier = remember { StdStartPadding.weight(1f) }, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) } - Row(verticalAlignment = Alignment.CenterVertically) { AboutDisplay(baseAuthor, accountViewModel) } - } - - Column( - modifier = StdStartPadding, - verticalArrangement = Arrangement.Center, - ) { + ListItem( + leadingContent = { + UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) + }, + headlineContent = { + UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) + }, + supportingContent = { ZapAmount(zapNote, accountViewModel) - } + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserActionOptions(baseAuthor, accountViewModel, nav) + } + }, + ) +} - Row(modifier = StdStartPadding) { - UserActionOptions(baseAuthor, accountViewModel, nav) - } - } +@Composable +private fun RenderZapNoteSlim( + baseAuthor: User, + zapNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + SlimListItem( + leadingContent = { + UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav) + }, + headlineContent = { + UsernameDisplay(baseAuthor, accountViewModel = accountViewModel) + }, + supportingContent = { + ZapAmount(zapNote, accountViewModel) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + UserActionOptions(baseAuthor, accountViewModel, nav) + } + }, + ) } @Composable @@ -154,6 +205,20 @@ private fun ZapAmount( } } +@Composable +fun AboutDisplayNoFormat( + baseAuthor: User, + accountViewModel: AccountViewModel, +) { + val aboutMe by observeUserAboutMe(baseAuthor, accountViewModel) + + Text( + aboutMe, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + @Composable fun AboutDisplay( baseAuthor: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 4dca81854..c323c467c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,13 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -36,20 +32,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -76,8 +66,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.Room import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey @@ -316,6 +308,7 @@ private fun UserRoomCompose( val lastReadTime by accountViewModel.account.loadLastReadFlow("Room/${room.hashCode()}").collectAsStateWithLifecycle() if ((lastMessage.createdAt() ?: Long.MIN_VALUE) > lastReadTime) { + Spacer(modifier = Height4dpModifier) NewItemsBubble() } }, @@ -412,6 +405,7 @@ fun ChannelName( } if (hasNewMessages) { + Spacer(modifier = Height4dpModifier) NewItemsBubble() } }, @@ -434,23 +428,5 @@ private fun TimeAgo(channelLastTime: Long?) { @Composable fun NewItemsBubble() { - Box( - modifier = - Modifier - .padding(start = 3.dp) - .width(10.dp) - .height(10.dp) - .clip(shape = CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center, - ) { - Text( - "", - color = Color.White, - textAlign = TextAlign.Center, - fontSize = 12.sp, - maxLines = 1, - modifier = Modifier.wrapContentHeight().align(Alignment.Center), - ) - } + Box(MaterialTheme.colorScheme.newItemBubbleModifier) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt index acd98d7cb..b82dc987e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/LnZapFeedView.kt @@ -21,15 +21,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty @@ -82,11 +79,7 @@ private fun LnZapFeedLoaded( ) { itemsIndexed(items, key = { _, item -> item.zapEvent.idHex }) { _, item -> ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) - - HorizontalDivider( - modifier = Modifier.padding(top = 10.dp), - thickness = DividerThickness, - ) + HorizontalDivider(thickness = DividerThickness) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 0aa4a5f27..217d898ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -27,10 +27,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme @@ -253,18 +251,28 @@ val DarkRelayIconModifier = val darkLargeProfilePictureModifier = Modifier - .width(120.dp) - .height(120.dp) + .size(120.dp) .clip(shape = CircleShape) .border(3.dp, DarkColorPalette.onBackground, CircleShape) val lightLargeProfilePictureModifier = Modifier - .width(120.dp) - .height(120.dp) + .size(120.dp) .clip(shape = CircleShape) .border(3.dp, LightColorPalette.onBackground, CircleShape) +val darkNewItemBubbleModifier = + Modifier + .size(10.dp) + .clip(shape = CircleShape) + .background(DarkColorPalette.primary) + +val lightNewItemBubbleModifier = + Modifier + .size(10.dp) + .clip(shape = CircleShape) + .background(LightColorPalette.primary) + val RichTextDefaults = RichTextStyle().resolveDefaults() val MarkDownStyleOnDark = @@ -462,6 +470,10 @@ val ColorScheme.selectedReactionBoxModifier: Modifier val ColorScheme.largeProfilePictureModifier: Modifier get() = if (isLight) lightLargeProfilePictureModifier else darkLargeProfilePictureModifier +@Suppress("ModifierFactoryExtensionFunction") +val ColorScheme.newItemBubbleModifier: Modifier + get() = if (isLight) lightNewItemBubbleModifier else darkNewItemBubbleModifier + val chartLightColors = VicoTheme( candlestickCartesianLayerColors =