compose-lobsters/common/src/main/kotlin/dev/msfjarvis/claw/common/posts/LobstersCard.kt

309 lines
9.8 KiB
Kotlin

/*
* Copyright © 2021-2024 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.posts
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Comment
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
import dev.msfjarvis.claw.model.LinkMetadata
import dev.msfjarvis.claw.model.UIPost
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun LobstersCard(
post: UIPost,
postActions: PostActions,
refresh: () -> Unit,
modifier: Modifier = Modifier,
) {
var localReadState by remember(post) { mutableStateOf(post.isRead) }
var localSavedState by remember(post) { mutableStateOf(post.isSaved) }
Box(
modifier =
modifier
.fillMaxWidth()
.clickable {
postActions.viewPost(post.shortId, post.url, post.commentsUrl)
localReadState = true
refresh()
}
.background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, bottom = 2.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PostDetails(
post = post,
isRead = localReadState,
singleLineTitle = true,
modifier = Modifier.weight(1f),
)
Column(
modifier = Modifier.wrapContentHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SaveButton(
isSaved = localSavedState,
modifier =
Modifier.clickable(role = Role.Button) {
localSavedState = !localSavedState
postActions.toggleSave(post)
refresh()
},
)
HorizontalDivider(modifier = Modifier.width(48.dp))
CommentsButton(
commentCount = post.commentCount,
modifier =
Modifier.clickable(
role = Role.Button,
onClick = {
postActions.viewComments(post.shortId)
localReadState = true
refresh()
},
),
)
}
}
}
}
@Composable
fun PostDetails(
post: UIPost,
isRead: Boolean,
singleLineTitle: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
PostTitle(title = post.title, isRead = isRead, singleLineTitle = singleLineTitle)
TagRow(tags = post.tags.toImmutableList())
Spacer(Modifier.height(4.dp))
Submitter(
text = AnnotatedString("Submitted by ${post.submitter}"),
avatarUrl = "https://lobste.rs/avatars/${post.submitter}-100.png",
contentDescription = "User avatar for ${post.submitter}",
)
}
}
@Composable
internal fun PostTitle(
title: String,
isRead: Boolean,
singleLineTitle: Boolean,
modifier: Modifier = Modifier,
) {
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (isRead) FontWeight.Normal else FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
maxLines = if (singleLineTitle) 1 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
internal fun Submitter(
text: AnnotatedString,
avatarUrl: String,
contentDescription: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
NetworkImage(
url = avatarUrl,
placeholder = Icons.Filled.AccountCircle,
contentDescription = contentDescription,
modifier = Modifier.requiredSize(24.dp).clip(CircleShape),
placeholderTintColor = MaterialTheme.colorScheme.onBackground,
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
@Composable
private fun SaveButton(isSaved: Boolean, modifier: Modifier = Modifier) {
Crossfade(targetState = isSaved, label = "save-button") { saved ->
Box(modifier = modifier.padding(vertical = 12.dp)) {
Icon(
imageVector = if (saved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = if (saved) "Remove from saved posts" else "Add to saved posts",
modifier = Modifier.align(Alignment.Center).testTag("save_button"),
)
}
}
}
@Composable
private fun CommentsButton(commentCount: Int?, modifier: Modifier = Modifier) {
BadgedBox(
modifier = modifier.padding(vertical = 12.dp),
badge = {
if (commentCount != null) {
Badge(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
// Required to make the badge label look visually okay
modifier = Modifier.absoluteOffset(y = (-8).dp),
) {
Text(
text = commentCount.toString(),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.labelMedium,
)
}
}
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Comment,
tint = MaterialTheme.colorScheme.secondary,
contentDescription = "Open comments",
modifier = Modifier.testTag("comments_button"),
)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
internal fun TagRow(tags: ImmutableList<String>, modifier: Modifier = Modifier) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
tags.forEach { tag -> TagText(tag = tag) }
}
}
@Composable
private fun TagText(tag: String, modifier: Modifier = Modifier) {
Text(
text = tag,
modifier =
modifier
.background(MaterialTheme.colorScheme.tertiaryContainer, CircleShape)
.padding(vertical = 4.dp, horizontal = 12.dp),
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.labelLarge,
)
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val TEST_POST_ACTIONS =
object : PostActions {
override fun viewPost(postId: String, postUrl: String, commentsUrl: String) {}
override fun viewComments(postId: String) {}
override fun viewCommentsPage(post: UIPost) {}
override fun toggleSave(post: UIPost) {}
override suspend fun getComments(postId: String): UIPost {
return UIPost(
shortId = "ooga",
title = "Simple Anomaly Detection Using Plain SQL",
url = "https://hakibenita.com/sql-anomaly-detection",
createdAt = "2020-09-21T08:04:24.000-05:00",
commentCount = 1,
commentsUrl = "https://lobste.rs/s/q1hh1g/simple_anomaly_detection_using_plain_sql",
tags = listOf("databases", "apis"),
description = "",
submitter = "Haki",
comments = emptyList(),
)
}
override suspend fun getLinkMetadata(url: String): LinkMetadata {
return LinkMetadata("", "")
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val TEST_POST =
UIPost(
shortId = "ooga",
title = "Simple Anomaly Detection Using Plain SQL",
url = "https://hakibenita.com/sql-anomaly-detection",
createdAt = "2020-09-21T08:04:24.000-05:00",
commentCount = 1,
commentsUrl = "https://lobste.rs/s/q1hh1g/simple_anomaly_detection_using_plain_sql",
submitter = "Haki",
tags = listOf("databases", "apis"),
description = "",
isSaved = true,
isRead = true,
)
@ThemePreviews
@Composable
private fun LobstersCardPreview() {
LobstersTheme { LobstersCard(post = TEST_POST, postActions = TEST_POST_ACTIONS, refresh = {}) }
}