From 80fd92f32bd273742ab4bd6e55e204b05ea5a50f Mon Sep 17 00:00:00 2001 From: Alex Gavrishev Date: Sat, 16 Mar 2024 17:44:16 +0200 Subject: [PATCH] Added new article style: compact card layout (#243) --- .../feeder/archmodel/SettingsStore.kt | 1 + .../ui/compose/feed/FeedItemCompactCard.kt | 288 ++++++++++++++++++ .../feeder/ui/compose/feed/FeedScreen.kt | 6 +- .../compose/feed/SwipeableFeedItemPreview.kt | 25 ++ app/src/main/res/values/strings.xml | 1 + 5 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedItemCompactCard.kt diff --git a/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt b/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt index 35233b9ed..ec69b515e 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/archmodel/SettingsStore.kt @@ -674,6 +674,7 @@ enum class FeedItemStyle( @StringRes val stringId: Int, ) { CARD(R.string.feed_item_style_card), + COMPACT_CARD(R.string.feed_item_style_compact_card), COMPACT(R.string.feed_item_style_compact), SUPER_COMPACT(R.string.feed_item_style_super_compact), } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedItemCompactCard.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedItemCompactCard.kt new file mode 100644 index 000000000..256b18e40 --- /dev/null +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedItemCompactCard.kt @@ -0,0 +1,288 @@ +package com.nononsenseapps.feeder.ui.compose.feed + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Terrain +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Precision +import coil.size.Scale +import coil.size.Size +import coil.size.pxOrElse +import com.nononsenseapps.feeder.R +import com.nononsenseapps.feeder.db.room.ID_UNSET +import com.nononsenseapps.feeder.model.EnclosureImage +import com.nononsenseapps.feeder.ui.compose.coil.rememberTintedVectorPainter +import com.nononsenseapps.feeder.ui.compose.minimumTouchSize +import com.nononsenseapps.feeder.ui.compose.theme.FeederTheme +import com.nononsenseapps.feeder.ui.compose.utils.ThemePreviews +import java.net.URL +import java.time.Instant +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +sealed interface FeedItemEvent { + data object MarkAboveAsRead: FeedItemEvent + data object MarkBelowAsRead: FeedItemEvent + data object ShareItem: FeedItemEvent + data object ToggleBookmarked: FeedItemEvent + data object DismissDropdown: FeedItemEvent +} + +@Immutable +data class FeedItemState( + val item: FeedListItem, + val showThumbnail: Boolean = true, + val bookmarkIndicator: Boolean = true, + val dropDownMenuExpanded: Boolean = false, + val showReadingTime: Boolean = false, + val maxLines: Int = 2 +) + +private val iconSize = 24.dp +private val gradientColors = listOf(Color.Black.copy(alpha = 0.8f), Color.Black.copy(alpha = 0.38f), Color.Black.copy(alpha = 0.8f)) + +@Composable +fun FeedItemCompactCard( + state: FeedItemState, + onEvent: (FeedItemEvent) -> Unit = { }, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier, + shape = MaterialTheme.shapes.medium + ) { + BoxWithConstraints( + modifier = Modifier + .requiredHeightIn(min = minimumTouchSize) + .fillMaxWidth() + ) { + if (state.showThumbnail) { + val sizePx = with(LocalDensity.current) { + val width = maxWidth.roundToPx() + Size(width, (width * 9) / 16) + } + val gradient = Brush.verticalGradient( + colors = gradientColors, + startY = 0f, + endY = sizePx.height.pxOrElse { 0 }.toFloat() + ) + val imageUrl = state.item.image?.url + if (imageUrl != null) { + FeedItemThumbnail( + imageUrl = imageUrl, + sizePx = sizePx + ) + } + Box(modifier = Modifier + .matchParentSize() + .background(gradient)) + } + + Row(modifier = Modifier + .height(iconSize) + .padding(top = 8.dp, end = 8.dp, start = 8.dp) + .align(Alignment.TopEnd) + ) { + if (state.item.bookmarked && state.bookmarkIndicator) { + FeedItemSavedIndicator(size = iconSize, modifier = modifier) + } + if (state.item.unread) { + FeedItemNewIndicator(size = iconSize, modifier = modifier) + } + state.item.feedImageUrl?.toHttpUrlOrNull()?.also { + FeedItemFeedIconIndicator( + feedImageUrl = it.toString(), + size = iconSize, + modifier = modifier, + ) + } + } + + Box(modifier = Modifier + .width(maxWidth) + .padding(top = 48.dp) + .align(Alignment.BottomCenter) + ) { + FeedItemTitle( + state = state, + onEvent = onEvent, + ) + } + } + } +} + +@Composable +private fun FeedItemTitle( + state: FeedItemState, + onEvent: (FeedItemEvent) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 8.dp), + ) { + val textColor = when { + !state.showThumbnail -> LocalContentColor.current + state.item.unread -> Color.White + else -> Color.White.copy(alpha = 0.74f) + } + CompositionLocalProvider(LocalContentColor provides textColor) { + FeedItemText( + item = state.item, + onMarkAboveAsRead = { onEvent(FeedItemEvent.MarkAboveAsRead) }, + onMarkBelowAsRead = { onEvent(FeedItemEvent.MarkBelowAsRead) }, + onShareItem = { onEvent(FeedItemEvent.ShareItem) }, + onToggleBookmarked = { onEvent(FeedItemEvent.ToggleBookmarked) }, + dropDownMenuExpanded = state.dropDownMenuExpanded, + onDismissDropdown = { onEvent(FeedItemEvent.DismissDropdown) }, + maxLines = state.maxLines, + showOnlyTitle = true, + showReadingTime = state.showReadingTime + ) + } + } +} + +@Composable +private fun FeedItemThumbnail(imageUrl: String?, sizePx: Size) { + if (imageUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .listener( + onError = { a, b -> + Log.e("FEEDER_CARD", "error ${a.data}", b.throwable) + }, + ) + .scale(Scale.FILL) + .size(sizePx) + .precision(Precision.INEXACT) + .build(), + placeholder = rememberTintedVectorPainter(Icons.Outlined.Terrain), + error = rememberTintedVectorPainter(Icons.Outlined.ErrorOutline), + contentDescription = stringResource(id = R.string.article_image), + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .fillMaxWidth() + .aspectRatio(16.0f / 9.0f) + .alpha(0.74f), + ) + } +} + +@Composable +@ThemePreviews +private fun Preview() { + FeederTheme { + FeedItemCompactCard( + state = FeedItemState( + item = FeedListItem( + title = "title", + snippet = "snippet which is quite long as you might expect from a snipper of a story. It keeps going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and snowing", + feedTitle = "Super Duper Feed One two three hup di too dasf dsaf asd fsa dfasdf", + pubDate = "Jun 9, 2021", + unread = true, + image = null, + link = null, + id = ID_UNSET, + bookmarked = true, + feedImageUrl = null, + primarySortTime = Instant.EPOCH, + rawPubDate = null, + wordCount = 0 + ) + ) + ) + } +} + +@Composable +@ThemePreviews +private fun PreviewWithImageUnread() { + FeederTheme { + Box( + modifier = Modifier.width((300 - 2 * 16).dp), + ) { + FeedItemCompactCard( + state = FeedItemState( + item = FeedListItem( + title = "title can be one line", + snippet = "snippet which is quite long as you might expect from a snipper of a story. It keeps going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and snowing", + feedTitle = "Super Feed", + pubDate = "Jun 9, 2021", + unread = true, + image = EnclosureImage(url = "blabal", length = 0), + link = null, + id = ID_UNSET, + bookmarked = false, + feedImageUrl = URL("https://foo/bar.png"), + primarySortTime = Instant.EPOCH, + rawPubDate = null, + wordCount = 0 + ) + ) + ) + } + } +} + +@Composable +@ThemePreviews +private fun PreviewWithImageRead() { + FeederTheme { + Box( + modifier = Modifier.width((300 - 2 * 16).dp), + ) { + FeedItemCompactCard( + state = FeedItemState( + item = FeedListItem( + title = "title can be one line", + snippet = "snippet which is quite long as you might expect from a snipper of a story. It keeps going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and going and snowing", + feedTitle = "Super Duper Feed", + pubDate = "Jun 9, 2021", + unread = false, + image = EnclosureImage(url = "blabal", length = 0), + link = null, + id = ID_UNSET, + bookmarked = true, + feedImageUrl = null, + primarySortTime = Instant.EPOCH, + rawPubDate = null, + wordCount = 0 + ) + ) + ) + } + } +} diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt index 4750b18d6..d85a40551 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/FeedScreen.kt @@ -1151,6 +1151,7 @@ fun FeedListContent( val arrangement = when (viewState.feedItemStyle) { FeedItemStyle.CARD -> Arrangement.spacedBy(LocalDimens.current.margin) + FeedItemStyle.COMPACT_CARD -> Arrangement.spacedBy(LocalDimens.current.margin) FeedItemStyle.COMPACT -> Arrangement.spacedBy(0.dp) FeedItemStyle.SUPER_COMPACT -> Arrangement.spacedBy(0.dp) } @@ -1177,6 +1178,7 @@ fun FeedListContent( ).run { when (viewState.feedItemStyle) { FeedItemStyle.CARD -> addMargin(horizontal = LocalDimens.current.margin) + FeedItemStyle.COMPACT_CARD -> addMargin(horizontal = LocalDimens.current.margin) // No margin since dividers FeedItemStyle.COMPACT, FeedItemStyle.SUPER_COMPACT -> this } @@ -1285,7 +1287,8 @@ fun FeedListContent( onItemClick(previewItem.id) } - if (viewState.feedItemStyle != FeedItemStyle.CARD) { + if (viewState.feedItemStyle != FeedItemStyle.CARD + && viewState.feedItemStyle != FeedItemStyle.COMPACT_CARD) { if (itemIndex < pagedFeedItems.itemCount - 1) { Divider( modifier = @@ -1371,6 +1374,7 @@ fun FeedGridContent( val arrangement = when (feedItemStyle) { FeedItemStyle.CARD -> Arrangement.spacedBy(LocalDimens.current.gutter) + FeedItemStyle.COMPACT_CARD -> Arrangement.spacedBy(LocalDimens.current.gutter) FeedItemStyle.COMPACT -> Arrangement.spacedBy(LocalDimens.current.gutter) FeedItemStyle.SUPER_COMPACT -> Arrangement.spacedBy(LocalDimens.current.gutter) } diff --git a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/SwipeableFeedItemPreview.kt b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/SwipeableFeedItemPreview.kt index dc34e3992..4199cf638 100644 --- a/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/SwipeableFeedItemPreview.kt +++ b/app/src/main/java/com/nononsenseapps/feeder/ui/compose/feed/SwipeableFeedItemPreview.kt @@ -257,6 +257,31 @@ fun SwipeableFeedItemPreview( ) } + FeedItemStyle.COMPACT_CARD -> { + FeedItemCompactCard( + state = FeedItemState( + item = item, + showThumbnail = showThumbnail && !compactLandscape, + dropDownMenuExpanded = dropDownMenuExpanded, + bookmarkIndicator = bookmarkIndicator, + maxLines = maxLines, + showReadingTime = showReadingTime + ), + onEvent = { event -> + when (event) { + FeedItemEvent.DismissDropdown -> { dropDownMenuExpanded = false } + FeedItemEvent.MarkAboveAsRead -> onMarkAboveAsRead() + FeedItemEvent.MarkBelowAsRead -> onMarkBelowAsRead() + FeedItemEvent.ShareItem -> onShareItem() + FeedItemEvent.ToggleBookmarked -> onToggleBookmarked() + } + }, + modifier = Modifier + .offset { IntOffset(swipeableState.offset.value.roundToInt(), 0) } + .graphicsLayer(alpha = itemAlpha), + ) + } + FeedItemStyle.COMPACT -> { FeedItemCompact( item = item, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab8ba1a04..cf27100ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -157,6 +157,7 @@ Article style Card + Compact Card Compact Super compact Generate extra unique IDs