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
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.R
|
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.components.MyAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
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.WatchAuthor
|
||||||
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
|
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
|
||||||
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
|
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.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.authorNotePictureForImageHeader
|
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||||
|
|
||||||
|
private const val WORDS_PER_MINUTE = 225
|
||||||
|
private val COVER_ASPECT_RATIO = 16f / 9f
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RenderLongFormContent(
|
fun RenderLongFormContent(
|
||||||
note: Note,
|
note: Note,
|
||||||
@@ -62,6 +82,7 @@ fun RenderLongFormContent(
|
|||||||
LongFormHeader(noteEvent, note, accountViewModel)
|
LongFormHeader(noteEvent, note, accountViewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongFormHeader(
|
fun LongFormHeader(
|
||||||
noteEvent: LongTextNoteEvent,
|
noteEvent: LongTextNoteEvent,
|
||||||
@@ -74,54 +95,168 @@ fun LongFormHeader(
|
|||||||
remember(noteEvent) {
|
remember(noteEvent) {
|
||||||
noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null }
|
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) {
|
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||||
image?.let {
|
LongFormCoverImage(image, note, accountViewModel)
|
||||||
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) },
|
|
||||||
)
|
|
||||||
|
|
||||||
WatchAuthor(baseNote = note, accountViewModel) { user ->
|
Column(
|
||||||
Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) {
|
modifier =
|
||||||
BaseUserPicture(user, Size55dp, accountViewModel, 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 {
|
title?.let {
|
||||||
Spacer(modifier = StdVertSpacer)
|
Text(
|
||||||
Text(
|
text = it,
|
||||||
text = it,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier =
|
maxLines = 2,
|
||||||
Modifier
|
overflow = TextOverflow.Ellipsis,
|
||||||
.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
)
|
||||||
color = Color.Gray,
|
}
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
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">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="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="loading_location">Loading location</string>
|
||||||
<string name="lack_location_permissions">No Location Permissions</string>
|
<string name="lack_location_permissions">No Location Permissions</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user