Rework TimelineItem UI and add three sizes : compact, regular and large

This commit is contained in:
Shinokuni 2024-07-12 15:17:53 +02:00
parent f57d39ec3b
commit 46800586e1
6 changed files with 512 additions and 185 deletions

View File

@ -1,42 +1,18 @@
package com.readrops.app.timelime
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.readrops.api.utils.DateUtils
import com.readrops.app.R
import com.readrops.app.util.components.IconText
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.VeryShortSpacer
import com.readrops.app.util.theme.spacing
import com.readrops.app.util.DefaultPreview
import com.readrops.app.util.theme.ReadropsTheme
import com.readrops.db.entities.Folder
import com.readrops.db.pojo.ItemWithFeed
import kotlin.math.roundToInt
import org.joda.time.LocalDateTime
enum class TimelineItemSize {
COMPACT,
REGULAR,
LARGE
}
@Composable
fun TimelineItem(
@ -45,155 +21,94 @@ fun TimelineItem(
onFavorite: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
compactLayout: Boolean = false,
size: TimelineItemSize = TimelineItemSize.LARGE,
) {
Card(
modifier = modifier
.padding(horizontal = MaterialTheme.spacing.shortSpacing)
.alpha(if (itemWithFeed.item.isRead) 0.6f else 1f)
.clickable { onClick() }
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(
start = MaterialTheme.spacing.shortSpacing,
end = MaterialTheme.spacing.shortSpacing,
top = MaterialTheme.spacing.shortSpacing,
)
) {
Column(
modifier = Modifier.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = itemWithFeed.feedIconUrl,
error = painterResource(id = R.drawable.ic_rss_feed_grey),
contentDescription = itemWithFeed.feedName,
placeholder = painterResource(R.drawable.ic_rss_feed_grey),
modifier = Modifier.size(24.dp)
)
VeryShortSpacer()
Text(
text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (itemWithFeed.bgColor != 0) Color(itemWithFeed.bgColor) else MaterialTheme.colorScheme.onSurface,
)
}
}
Row {
Surface(
color = if (itemWithFeed.bgColor != 0) Color(itemWithFeed.bgColor) else MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(48.dp)
) {
Text(
text = DateUtils.formattedDateByLocal(itemWithFeed.item.pubDate!!),
style = MaterialTheme.typography.labelMedium,
color = if (itemWithFeed.bgColor != 0) Color.White else MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.shortSpacing,
vertical = MaterialTheme.spacing.veryShortSpacing
)
)
}
}
}
ShortSpacer()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
.padding(start = MaterialTheme.spacing.shortSpacing)
) {
if (itemWithFeed.folder != null) {
IconText(
icon = painterResource(id = R.drawable.ic_folder_grey),
text = itemWithFeed.folder!!.name!!,
style = MaterialTheme.typography.labelMedium
)
Text(
text = "·",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.veryShortSpacing)
)
}
IconText(
icon = painterResource(id = R.drawable.ic_hourglass_empty),
text = if (itemWithFeed.item.readTime < 1) "< 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins",
style = MaterialTheme.typography.labelMedium
)
}
ShortSpacer()
Text(
text = itemWithFeed.item.title!!,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
when (size) {
TimelineItemSize.COMPACT -> {
CompactTimelineItem(
itemWithFeed = itemWithFeed,
onClick = onClick,
onFavorite = onFavorite,
onShare = onShare,
modifier = modifier
)
}
TimelineItemSize.REGULAR -> {
RegularTimelineItem(
itemWithFeed = itemWithFeed,
onClick = onClick,
onFavorite = onFavorite,
onShare = onShare,
modifier = modifier
)
}
TimelineItemSize.LARGE -> {
LargeTimelineItem(
itemWithFeed = itemWithFeed,
onClick = onClick,
onFavorite = onFavorite,
onShare = onShare,
modifier = modifier
)
ShortSpacer()
if (itemWithFeed.item.cleanDescription != null && !compactLayout) {
Text(
text = itemWithFeed.item.cleanDescription!!,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
)
ShortSpacer()
}
if (itemWithFeed.item.hasImage && !compactLayout) {
AsyncImage(
model = itemWithFeed.item.imageLink,
contentDescription = itemWithFeed.item.title!!,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(16f / 9f)
.fillMaxWidth()
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.shortSpacing)
) {
Icon(
imageVector = if (itemWithFeed.item.isStarred) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = null,
modifier = Modifier.clickable { onFavorite() }
)
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = null,
modifier = Modifier.clickable { onShare() }
)
}
}
}
}
private val itemWithFeed = ItemWithFeed(
item = com.readrops.db.entities.Item(
title = "This is a not so long item title",
pubDate = LocalDateTime.now(),
cleanDescription = """Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Donec a tortor neque. Nam ultrices, diam ac congue finibus, tortor sem congue urna,
at finibus elit libero at mi. Etiam hendrerit sapien eu porta feugiat. Duis porttitor"""
.replace("\n", "")
.trimMargin(),
imageLink = ""
),
feedName = "feed name",
color = 0,
bgColor = 0,
feedId = 0,
feedIconUrl = "",
websiteUrl = "",
folder = Folder(name = "Folder name")
)
@DefaultPreview
@Composable
private fun RegularTimelineItemPreview() {
ReadropsTheme {
RegularTimelineItem(
itemWithFeed = itemWithFeed,
onClick = {},
onFavorite = {},
onShare = {},
)
}
}
@DefaultPreview
@Composable
private fun CompactTimelineItemPreview() {
ReadropsTheme {
CompactTimelineItem(
itemWithFeed = itemWithFeed,
onClick = {},
onFavorite = {},
onShare = {},
)
}
}
@DefaultPreview
@Composable
private fun LargeTimelineItemPreview() {
ReadropsTheme {
LargeTimelineItem(
itemWithFeed = itemWithFeed,
onClick = {},
onFavorite = {},
onShare = {},
)
}
}

View File

@ -0,0 +1,397 @@
package com.readrops.app.timelime
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.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.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.readrops.api.utils.DateUtils
import com.readrops.app.R
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.spacing
import com.readrops.db.pojo.ItemWithFeed
import org.joda.time.LocalDateTime
import kotlin.math.roundToInt
@Composable
fun RegularTimelineItem(
itemWithFeed: ItemWithFeed,
onClick: () -> Unit,
onFavorite: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier
) {
TimelineItemContainer(
isRead = itemWithFeed.item.isRead,
onClick = onClick,
modifier = modifier
) {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
) {
TimelineItemHeader(
feedName = itemWithFeed.feedName,
feedIconUrl = itemWithFeed.feedIconUrl,
feedColor = itemWithFeed.bgColor,
folderName = itemWithFeed.folder?.name,
date = itemWithFeed.item.pubDate!!,
duration = itemWithFeed.item.readTime,
isStarred = itemWithFeed.item.isStarred,
onFavorite = onFavorite,
onShare = onShare
)
ShortSpacer()
TimelineItemTitle(title = itemWithFeed.item.title!!)
ShortSpacer()
TimelineItemBadge(
date = itemWithFeed.item.pubDate!!,
duration = itemWithFeed.item.readTime,
color = itemWithFeed.bgColor
)
}
}
}
@Composable
fun CompactTimelineItem(
itemWithFeed: ItemWithFeed,
onClick: () -> Unit,
onFavorite: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = modifier
.fillMaxWidth()
.alpha(if (itemWithFeed.item.isRead) 0.6f else 1f)
.clickable { onClick() }
) {
Column(
modifier = Modifier.padding(
start = MaterialTheme.spacing.shortSpacing,
end = MaterialTheme.spacing.shortSpacing,
top = MaterialTheme.spacing.shortSpacing
)
) {
TimelineItemHeader(
feedName = itemWithFeed.feedName,
feedIconUrl = itemWithFeed.feedIconUrl,
feedColor = itemWithFeed.bgColor,
folderName = itemWithFeed.folder?.name,
onFavorite = onFavorite,
onShare = onShare,
date = itemWithFeed.item.pubDate!!,
duration = itemWithFeed.item.readTime,
isStarred = itemWithFeed.item.isStarred,
displayActions = false
)
ShortSpacer()
TimelineItemTitle(title = itemWithFeed.item.title!!)
ShortSpacer()
HorizontalDivider(
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
)
}
}
}
@Composable
fun LargeTimelineItem(
itemWithFeed: ItemWithFeed,
onClick: () -> Unit,
onFavorite: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier
) {
if (itemWithFeed.item.cleanDescription == null && !itemWithFeed.item.hasImage) {
RegularTimelineItem(
itemWithFeed = itemWithFeed,
onClick = onClick,
onFavorite = onFavorite,
onShare = onShare
)
} else {
TimelineItemContainer(
isRead = itemWithFeed.item.isRead,
onClick = onClick,
modifier = modifier
) {
Column {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
) {
TimelineItemHeader(
feedName = itemWithFeed.feedName,
feedIconUrl = itemWithFeed.feedIconUrl,
feedColor = itemWithFeed.bgColor,
folderName = itemWithFeed.folder?.name,
date = itemWithFeed.item.pubDate!!,
duration = itemWithFeed.item.readTime,
isStarred = itemWithFeed.item.isStarred,
onFavorite = onFavorite,
onShare = onShare
)
ShortSpacer()
TimelineItemBadge(
date = itemWithFeed.item.pubDate!!,
duration = itemWithFeed.item.readTime,
color = itemWithFeed.bgColor
)
ShortSpacer()
TimelineItemTitle(title = itemWithFeed.item.title!!)
if (itemWithFeed.item.cleanDescription != null) {
ShortSpacer()
Text(
text = itemWithFeed.item.cleanDescription!!,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
if (itemWithFeed.item.hasImage) {
AsyncImage(
model = if (!LocalInspectionMode.current) {
itemWithFeed.item.imageLink
} else {
ImageRequest.Builder(LocalContext.current)
.data(R.drawable.ic_broken_image)
.build()
},
contentDescription = itemWithFeed.item.title!!,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(16f / 9f)
.fillMaxWidth()
)
}
}
}
}
}
@Composable
fun TimelineItemContainer(
isRead: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Card(
modifier = modifier
.padding(horizontal = MaterialTheme.spacing.shortSpacing)
.fillMaxWidth()
.alpha(if (isRead) 0.6f else 1f)
.clickable { onClick() }
) {
content()
}
}
@Composable
fun TimelineItemHeader(
feedName: String,
feedIconUrl: String?,
feedColor: Int,
folderName: String?,
date: LocalDateTime,
duration: Double,
isStarred: Boolean,
onFavorite: () -> Unit,
onShare: () -> Unit,
displayActions: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
AsyncImage(
model = feedIconUrl,
error = painterResource(id = R.drawable.ic_rss_feed_grey),
placeholder = painterResource(R.drawable.ic_rss_feed_grey),
contentDescription = feedName,
modifier = Modifier.size(24.dp)
)
ShortSpacer()
Column {
Text(
text = feedName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (feedColor != 0) {
Color(feedColor)
} else {
MaterialTheme.colorScheme.primary
},
)
if (!folderName.isNullOrEmpty()) {
Text(
text = folderName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
ShortSpacer()
if (displayActions) {
Row {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer,
) {
IconButton(
onClick = onFavorite
) {
Icon(
imageVector = if (isStarred) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = null,
)
}
}
ShortSpacer()
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primaryContainer
) {
IconButton(
onClick = onShare
) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
} else {
TimelineItemBadge(
date = date,
duration = duration,
color = feedColor
)
}
}
}
@Composable
fun TimelineItemTitle(
title: String
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
)
}
@Composable
fun TimelineItemBadge(
date: LocalDateTime,
duration: Double,
color: Int,
) {
val textColor = if (color != 0) Color.White else MaterialTheme.colorScheme.onPrimary
Surface(
color = if (color != 0) Color(color) else MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(48.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.shortSpacing,
vertical = MaterialTheme.spacing.veryShortSpacing
)
) {
Text(
text = DateUtils.formattedDateByLocal(date),
style = MaterialTheme.typography.labelSmall,
color = textColor
)
Text(
text = "·",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.veryShortSpacing),
color = textColor
)
Text(
text = if (duration > 1) {
stringResource(id = R.string.read_time, duration.roundToInt())
} else {
stringResource(id = R.string.read_time_lower_than_1)
},
style = MaterialTheme.typography.labelSmall,
color = textColor
)
}
}
}

View File

@ -356,8 +356,9 @@ object TimelineTab : Tab {
onShare = {
viewModel.shareItem(itemWithFeed.item, context)
},
compactLayout = true
size = TimelineItemSize.LARGE
)
}
}
}

View File

@ -0,0 +1,14 @@
package com.readrops.app.util
import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true
)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_NO,
showBackground = true
)
annotation class DefaultPreview

View File

@ -18,7 +18,7 @@
<string name="add_feed_unknownhost_error">Site inconnu</string>
<string name="by_author">par %1$s</string>
<string name="read_time">%1$s min</string>
<string name="read_time_lower_than_1">Moins d\'une minute</string>
<string name="read_time_lower_than_1">&lt; 1 min</string>
<string name="read_time_one_minute">1 min</string>
<string name="share_article">Partager l\'article</string>
<string name="open_url">Ouvrir le lien</string>

View File

@ -19,8 +19,8 @@
<string name="add_feed_connexion_error">Connection error</string>
<string name="add_feed_unknownhost_error">Unknown host</string>
<string name="by_author">by %1$s</string>
<string name="read_time">%1$s mins</string>
<string name="read_time_lower_than_1">Less than a minute</string>
<string name="read_time">%1$s min</string>
<string name="read_time_lower_than_1">&lt;1 min</string>
<string name="read_time_one_minute">1 min</string>
<string name="interpoint" translatable="false">·</string>
<string name="share_article">Share Article</string>