feat: redesign LongFormHeader with richer article card layout

- 16:9 cover image with rounded top corners
- Topic/hashtag chips row above title (up to 3)
- Bolder titleLarge headline, max 2 lines
- Gray summary with max 3 lines
- Author row with display name, time ago, and reading-time badge
This commit is contained in:
Claude
2026-04-16 22:05:03 +00:00
parent f0730ce4f7
commit 71537a8950
2 changed files with 183 additions and 46 deletions
@@ -20,19 +20,33 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.background
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -40,17 +54,23 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
private const val WORDS_PER_MINUTE = 225
private val COVER_ASPECT_RATIO = 16f / 9f
@Composable
fun RenderLongFormContent(
note: Note,
@@ -62,6 +82,7 @@ fun RenderLongFormContent(
LongFormHeader(noteEvent, note, accountViewModel)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LongFormHeader(
noteEvent: LongTextNoteEvent,
@@ -74,54 +95,168 @@ fun LongFormHeader(
remember(noteEvent) {
noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
}
val topics = remember(noteEvent) { noteEvent.topics().distinct().take(3) }
val readingMinutes = remember(noteEvent) { estimateReadingMinutes(noteEvent.content) }
Column(MaterialTheme.colorScheme.replyModifier) {
image?.let {
Box {
MyAsyncImage(
imageUrl = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel) },
onError = { DefaultImageHeader(note, accountViewModel) },
)
LongFormCoverImage(image, note, accountViewModel)
WatchAuthor(baseNote = note, accountViewModel) { user ->
Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) {
BaseUserPicture(user, Size55dp, accountViewModel, Modifier)
}
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
if (topics.isNotEmpty()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Size5dp),
verticalArrangement = Arrangement.spacedBy(Size5dp),
modifier = Modifier.padding(bottom = 8.dp),
) {
topics.forEach { TopicChip(it) }
}
}
} ?: run {
DefaultImageHeader(note, accountViewModel, Modifier.fillMaxWidth())
}
title?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
)
}
summary?.let {
Spacer(modifier = StdVertSpacer)
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
summary?.let {
Spacer(Modifier.padding(top = 6.dp))
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.padding(top = 12.dp))
AuthorMetaRow(note, readingMinutes, accountViewModel)
}
}
}
@Composable
private fun LongFormCoverImage(
image: String?,
note: Note,
accountViewModel: AccountViewModel,
) {
val imageShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp)
val imageModifier =
Modifier
.fillMaxWidth()
.aspectRatio(COVER_ASPECT_RATIO)
.clip(imageShape)
Box(imageModifier) {
if (image != null) {
MyAsyncImage(
imageUrl = image,
contentDescription = stringRes(R.string.preview_card_image_for, image),
contentScale = ContentScale.Crop,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = imageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(note, accountViewModel, imageModifier) },
onError = { DefaultImageHeader(note, accountViewModel, imageModifier) },
)
} else {
DefaultImageHeader(note, accountViewModel, imageModifier)
}
}
}
@Composable
private fun TopicChip(topic: String) {
Text(
text = "#$topic",
style = MaterialTheme.typography.labelSmall,
fontSize = Font12SP,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(CircleShape)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f))
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
@Composable
private fun AuthorMetaRow(
note: Note,
readingMinutes: Int,
accountViewModel: AccountViewModel,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
WatchAuthor(baseNote = note, accountViewModel) { user ->
BaseUserPicture(user, 25.dp, accountViewModel, Modifier)
}
Spacer(Modifier.padding(start = 8.dp))
NoteUsernameDisplay(
baseNote = note,
weight = Modifier.weight(1f, fill = false),
textColor = MaterialTheme.colorScheme.onSurface,
accountViewModel = accountViewModel,
)
MetaSeparator()
TimeAgo(note)
MetaSeparator()
ReadingTimeBadge(readingMinutes)
}
}
@Composable
private fun MetaSeparator() {
Text(
text = " · ",
color = MaterialTheme.colorScheme.grayText,
style = MaterialTheme.typography.bodySmall,
)
}
@Composable
private fun ReadingTimeBadge(minutes: Int) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.grayText,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.padding(start = 4.dp))
Text(
text = stringRes(R.string.long_form_reading_minutes, minutes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
private fun estimateReadingMinutes(content: String): Int {
if (content.isBlank()) return 1
val words = content.split(Regex("\\s+")).count { it.isNotBlank() }
return maxOf(1, (words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE)
}
+2
View File
@@ -1079,6 +1079,8 @@
<string name="hashtag_exclusive">Hashtag-exclusive Post</string>
<string name="hashtag_exclusive_explainer">Only followers of the hashtag will see it. Your general followers won\'t see it.</string>
<string name="long_form_reading_minutes">%1$d min read</string>
<string name="loading_location">Loading location</string>
<string name="lack_location_permissions">No Location Permissions</string>