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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user