From e9ed503b66fdd4c1914b5be847b07e615d3478cf Mon Sep 17 00:00:00 2001 From: Rikin Marfatia Date: Tue, 6 Aug 2024 14:42:55 -0700 Subject: [PATCH] Feed + Comment Polish (#99) This diff is a quick design pass to solidify what feed items, comments, and all the general components look like. This also addresses loading states and makes sure everything looks good as a v1. --- .../com/emergetools/hackernews/AppActivity.kt | 2 +- .../features/bookmarks/BookmarksScreen.kt | 9 +- .../features/comments/CommentsDomain.kt | 9 + .../features/comments/CommentsScreen.kt | 437 ++++++++++-------- .../features/settings/SettingsScreen.kt | 2 +- .../features/stories/StoriesScreen.kt | 355 +++++++++----- .../emergetools/hackernews/ui/theme/Type.kt | 7 +- .../src/main/res/drawable/ic_time_outline.xml | 13 + .../app/src/main/res/drawable/ic_upvote.xml | 10 + 9 files changed, 528 insertions(+), 316 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_time_outline.xml create mode 100644 android/app/src/main/res/drawable/ic_upvote.xml diff --git a/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt b/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt index 280354cd..ad9d1b20 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/AppActivity.kt @@ -79,7 +79,7 @@ fun App() { selected = navItem.selected, colors = NavigationBarItemDefaults.colors( selectedIconColor = MaterialTheme.colorScheme.primary, - indicatorColor = MaterialTheme.colorScheme.primaryContainer + indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) ), onClick = { model.actions(AppAction.NavItemSelected(navItem)) diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/bookmarks/BookmarksScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/bookmarks/BookmarksScreen.kt index 327b7203..4bc6421a 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/bookmarks/BookmarksScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/bookmarks/BookmarksScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.emergetools.hackernews.features.comments.CommentsDestinations +import com.emergetools.hackernews.features.stories.ListSeparator import com.emergetools.hackernews.features.stories.StoriesDestinations import com.emergetools.hackernews.features.stories.StoryItem import com.emergetools.hackernews.features.stories.StoryRow @@ -40,7 +42,7 @@ fun BookmarksScreen( style = MaterialTheme.typography.titleMedium ) LazyColumn { - items(items = state.bookmarks, key = { it.id }) { item -> + itemsIndexed(items = state.bookmarks, key = {_, item -> item.id }) { index, item -> StoryRow( item = item, onClick = { @@ -55,6 +57,11 @@ fun BookmarksScreen( navigator(BookmarksNavigation.GoToComments(CommentsDestinations.Comments(it.id))) } ) + if(index != state.bookmarks.lastIndex) { + ListSeparator( + lineColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } } } } diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt index ccbd8987..742387af 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsDomain.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.ZoneOffset +import java.time.ZonedDateTime sealed interface CommentsState { val headerState: HeaderState @@ -40,6 +41,7 @@ sealed interface CommentsState { val title: String, val author: String, val points: Int, + val timeLabel: String, val body: String?, val loggedIn: Boolean, val upvoted: Boolean, @@ -53,6 +55,7 @@ sealed interface CommentsState { title = title, author = author, points = points, + timeLabel = timeLabel, body = body, upvoted = upvoted, upvoteUrl = upvoteUrl @@ -104,6 +107,7 @@ sealed interface HeaderState { val title: String, val author: String, val points: Int, + val timeLabel: String, val body: String?, val upvoted: Boolean, val upvoteUrl: String, @@ -179,6 +183,11 @@ class CommentsViewModel( title = searchResponse.item.title ?: "", author = searchResponse.item.author ?: "", points = searchResponse.item.points ?: 0, + timeLabel = relativeTimeStamp( + epochSeconds = ZonedDateTime + .parse(searchResponse.item.createdAt) + .toEpochSecond() + ), body = searchResponse.item.text, loggedIn = loggedIn, upvoted = postPage.postInfo.upvoted, diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt index d0c415b1..a9f3a6e2 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/comments/CommentsScreen.kt @@ -1,12 +1,19 @@ package com.emergetools.hackernews.features.comments import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -24,15 +31,13 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ThumbUp -import androidx.compose.material.icons.rounded.ThumbUp import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,10 +54,14 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.emergetools.hackernews.R +import com.emergetools.hackernews.features.stories.MetadataButton +import com.emergetools.hackernews.features.stories.MetadataTag import com.emergetools.hackernews.ui.theme.HackerGreen import com.emergetools.hackernews.ui.theme.HackerNewsTheme import com.emergetools.hackernews.ui.theme.HackerOrange +import com.emergetools.hackernews.ui.theme.HackerRed @Composable fun CommentsScreen( @@ -67,7 +76,8 @@ fun CommentsScreen( ) { LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp) + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { item { ItemHeader( @@ -105,7 +115,7 @@ fun CommentsScreen( strokeWidth = 4f, cap = StrokeCap.Round, pathEffect = PathEffect.dashPathEffect( - intervals = floatArrayOf(20f, 20f) + intervals = floatArrayOf(10f, 10f) ) ) } @@ -173,7 +183,8 @@ private fun CommentsScreenPreview() { title = "Show HN: A new HN client for Android", author = "rikinm", points = 69, - body = null, + timeLabel = "2h ago", + body = "Hello There", loggedIn = false, upvoted = false, upvoteUrl = "", @@ -188,18 +199,17 @@ private fun CommentsScreenPreview() { timeLabel = "2d ago", upvoted = false, upvoteUrl = "", - children = listOf( - CommentState.Content( - id = 2, - level = 1, - author = "vasantm", - content = "Hello Parent", - timeLabel = "1h ago", - upvoted = false, - upvoteUrl = "", - children = listOf() - ) - ) + children = listOf() + ), + CommentState.Content( + id = 2, + level = 1, + author = "vasantm", + content = "Hello Parent", + timeLabel = "1h ago", + upvoted = false, + upvoteUrl = "", + children = listOf() ) ) ), @@ -233,8 +243,8 @@ fun CommentRow( .padding(start = startPadding) .fillMaxWidth() .heightIn(min = 80.dp) - .clip(RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)) - .background(color = MaterialTheme.colorScheme.surfaceContainer) + .clip(RoundedCornerShape(8.dp)) + .background(color = MaterialTheme.colorScheme.surface) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -246,22 +256,21 @@ fun CommentRow( verticalAlignment = Alignment.CenterVertically ) { Text( - text = state.author, - style = MaterialTheme.typography.labelSmall, - color = HackerOrange, - fontWeight = FontWeight.Medium - ) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = state.timeLabel, + text = "@${state.author}", style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium, - color = Color.Gray + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold ) + MetadataTag( + label = state.timeLabel + ) { + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_time_outline), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = "Time posted" + ) + } Spacer(modifier = Modifier.weight(1f)) Box( modifier = Modifier @@ -274,13 +283,13 @@ fun CommentRow( MaterialTheme.colorScheme.surfaceContainerHighest } ) - .padding(vertical = 4.dp, horizontal = 8.dp) + .padding(4.dp) .clickable { onLikeTapped(state) }, contentAlignment = Alignment.Center ) { Icon( modifier = Modifier.size(12.dp), - imageVector = Icons.Default.ThumbUp, + painter = painterResource(R.drawable.ic_upvote), tint = if (state.upvoted) { HackerGreen } else { @@ -294,53 +303,86 @@ fun CommentRow( Text( text = state.content.parseAsHtml(), style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onSurface, ) } } is CommentState.Loading -> { + val infiniteTransition = rememberInfiniteTransition("Skeleton") + val skeletonAlpha by infiniteTransition.animateFloat( + initialValue = 0.2f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "Skeleton Alpha" + ) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .width(40.dp) - .height(14.dp) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(HackerOrange) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) + ) + } + Spacer(modifier = Modifier.weight(1f)) Box( modifier = Modifier - .width(40.dp) - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(Color.Gray) - ) + .clip(CircleShape) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) + ) + } } Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Box( modifier = Modifier .fillMaxWidth() - .height(14.dp) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(Color.LightGray) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) Box( modifier = Modifier .fillMaxWidth() - .height(14.dp) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(Color.LightGray) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) Box( modifier = Modifier .fillMaxWidth(0.75f) - .height(14.dp) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(Color.LightGray) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) } } @@ -369,18 +411,7 @@ fun CommentRowPreview() { timeLabel = "2d ago", upvoted = false, upvoteUrl = "", - children = listOf( - CommentState.Content( - id = 2, - level = 1, - author = "vasantm", - content = "Hello Child", - timeLabel = "2h ago", - upvoted = false, - upvoteUrl = "", - children = listOf() - ) - ) + children = listOf() ), onLikeTapped = {} ) @@ -408,136 +439,155 @@ fun ItemHeader( onLikeTapped: (HeaderState.Content) -> Unit, ) { Column( - modifier = modifier - .background(color = MaterialTheme.colorScheme.background) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = modifier.background(color = MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { when (state) { is HeaderState.Content -> { - Text( - text = state.title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall - ) - if (state.body != null) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = state.body.parseAsHtml(), + text = state.title, color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.titleSmall, + fontSize = 20.sp ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { Row( - modifier = Modifier - .wrapContentSize() - .clip(CircleShape) - .background( - color = if (state.upvoted) { - HackerGreen.copy(alpha = 0.2f) - } else { - MaterialTheme.colorScheme.surfaceContainerHighest - } - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - .clickable { onLikeTapped(state) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - modifier = Modifier.size(12.dp), - imageVector = Icons.Rounded.ThumbUp, - tint = if (state.upvoted) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "@${state.author}", + color = HackerOrange, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + MetadataTag(label = state.timeLabel) { + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_time_outline), + tint = HackerRed, + contentDescription = "Time Posted" + ) + } + Spacer(modifier = Modifier.weight(1f)) + MetadataButton( + label = "${state.points}", + contentColor = if (state.upvoted) { HackerGreen } else { MaterialTheme.colorScheme.onSurface }, - contentDescription = "Upvote" - ) + onClick = { onLikeTapped(state) } + ) { + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_upvote), + tint = HackerGreen, + contentDescription = "Upvotes" + ) + } + } + } + if (state.body != null) { + Box( + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color = MaterialTheme.colorScheme.surface) + .padding(8.dp), + contentAlignment = Alignment.CenterStart + ) { Text( - text = "${state.points}", - color = if (state.upvoted) { - HackerGreen - } else { - MaterialTheme.colorScheme.onSurface - }, - style = MaterialTheme.typography.labelSmall + text = state.body.parseAsHtml(), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.labelSmall, ) } - Text( - text = "•", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall - ) - Text( - text = state.author, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) } } HeaderState.Loading -> { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(18.dp) - .clip(RoundedCornerShape(4.dp)) - .background(color = MaterialTheme.colorScheme.onBackground) - ) - Box( - modifier = Modifier - .fillMaxWidth(0.75f) - .height(18.dp) - .clip(RoundedCornerShape(4.dp)) - .background(color = MaterialTheme.colorScheme.onBackground) - ) - } - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(color = Color.LightGray) - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(color = Color.LightGray) - ) - Box( - modifier = Modifier - .fillMaxWidth(0.75f) - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(color = Color.LightGray) - ) + val infiniteTransition = rememberInfiniteTransition("Skeleton Loader") + val skeletonAlpha by infiniteTransition.animateFloat( + initialValue = 0.2f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "Skeleton Alpha" + ) + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(18.dp) + .clip(RoundedCornerShape(6.dp)) + .background(color = MaterialTheme.colorScheme.onBackground.copy(alpha = skeletonAlpha)) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.75f) + .height(18.dp) + .clip(RoundedCornerShape(6.dp)) + .background(color = MaterialTheme.colorScheme.onBackground.copy(alpha = skeletonAlpha)) + ) + } } - - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { Box( modifier = Modifier - .width(30.dp) - .height(14.dp) + .width(60.dp) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(Color.DarkGray) + .background(HackerOrange.copy(alpha = skeletonAlpha)) ) - Box( + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(HackerRed.copy(alpha = skeletonAlpha)) + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = skeletonAlpha)) + ) + } + Spacer(modifier = Modifier.weight(1f)) + Row( modifier = Modifier - .width(30.dp) - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.onBackground) - ) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)) + .padding(vertical = 4.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(HackerGreen.copy(alpha = skeletonAlpha)) + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = skeletonAlpha)) + ) + } } } } @@ -548,21 +598,28 @@ fun ItemHeader( @Composable private fun ItemHeaderPreview() { HackerNewsTheme { - ItemHeader( - state = HeaderState.Content( - id = 0L, - title = "Show HN: A super neat HN client for Android", - author = "rikinm", - points = 69, - body = "Hi there", - upvoted = false, - upvoteUrl = "", - ), + Box( modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - onLikeTapped = {} - ) + .background(MaterialTheme.colorScheme.background) + .padding(8.dp) + ) { + ItemHeader( + state = HeaderState.Content( + id = 0L, + title = "Show HN: A super neat HN client for Android", + author = "rikinm", + points = 69, + timeLabel = "2h ago", + body = "Wassup HN. I just built a sick new Hacker News Android client", + upvoted = false, + upvoteUrl = "", + ), + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + onLikeTapped = {} + ) + } } } @@ -570,13 +627,19 @@ private fun ItemHeaderPreview() { @Composable private fun ItemHeaderLoadingPreview() { HackerNewsTheme { - ItemHeader( - state = HeaderState.Loading, + Box( modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(), - onLikeTapped = {} - ) + .background(MaterialTheme.colorScheme.background) + .padding(8.dp) + ) { + ItemHeader( + state = HeaderState.Loading, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + onLikeTapped = {} + ) + } } } diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt index 4128d9b0..6bc703bf 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/settings/SettingsScreen.kt @@ -214,7 +214,7 @@ fun LoginCard( scaleY = iconScale } .size(12.dp), - imageVector = Icons.Rounded.ThumbUp, + painter = painterResource(R.drawable.ic_upvote), tint = likeColor, contentDescription = "Likes" ) diff --git a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt index 7b88637f..c91fa2e9 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/features/stories/StoriesScreen.kt @@ -1,8 +1,14 @@ package com.emergetools.hackernews.features.stories +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -12,19 +18,20 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh @@ -53,19 +60,19 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.emergetools.hackernews.R import com.emergetools.hackernews.features.comments.CommentsDestinations import com.emergetools.hackernews.ui.preview.AppStoreSnapshot +import com.emergetools.hackernews.ui.theme.HackerBlue +import com.emergetools.hackernews.ui.theme.HackerGreen import com.emergetools.hackernews.ui.theme.HackerNewsTheme import com.emergetools.hackernews.ui.theme.HackerOrange import com.emergetools.hackernews.ui.theme.HackerRed @@ -124,7 +131,7 @@ fun StoriesScreen( } } } - items(state.stories) { item -> + itemsIndexed(state.stories) { index, item -> StoryRow( modifier = Modifier.animateItem(), item = item, @@ -154,6 +161,11 @@ fun StoriesScreen( ) }, ) + if (index != state.stories.lastIndex) { + ListSeparator( + lineColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } } } } @@ -215,156 +227,194 @@ fun StoryRow( }, stiffness = Spring.StiffnessLow ), label = "Bookmark Height" ) - Row(modifier = modifier - .fillMaxWidth() - .heightIn(min = 80.dp) - .background(color = MaterialTheme.colorScheme.background) - .clip(shape = RectangleShape) - .drawWithContent { - drawContent() - val startX = size.width * 0.75f - val startY = 0f - val bookmarkWidth = 50f + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 100.dp) + .background(color = MaterialTheme.colorScheme.background) + .drawWithContent { + drawContent() + val startX = size.width * 0.75f + val startY = 0f + val bookmarkWidth = 50f - val path = Path().apply { - moveTo(startX, startY) - lineTo(startX, startY + bookmarkHeight) - lineTo(startX + bookmarkWidth / 2f, startY + bookmarkHeight * 0.75f) - lineTo(startX + bookmarkWidth, startY + bookmarkHeight) - lineTo(startX + bookmarkWidth, startY) - } + val path = Path().apply { + moveTo(startX, startY) + lineTo(startX, startY + bookmarkHeight) + lineTo(startX + bookmarkWidth / 2f, startY + bookmarkHeight * 0.75f) + lineTo(startX + bookmarkWidth, startY + bookmarkHeight) + lineTo(startX + bookmarkWidth, startY) + } - drawPath( - path, - color = HackerOrange, - ) - } - .combinedClickable(onClick = { - onClick(item) - }, onLongClick = { - onBookmark(item) - }) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 16.dp, alignment = Alignment.CenterHorizontally + drawPath( + path, + color = HackerOrange, + ) + } + .combinedClickable(onClick = { + onClick(item) + }, onLongClick = { + onBookmark(item) + }) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy( + space = 8.dp, + alignment = Alignment.CenterVertically ) ) { - Column( - modifier = Modifier - .wrapContentHeight() - .weight(1f), - verticalArrangement = Arrangement.Center + Text( + text = "@${item.author}", + color = HackerOrange, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = item.title, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.height(0.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = item.title, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall - ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "${item.score}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall - ) - Text( - text = "•", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall - ) - Text( - text = item.author, - color = HackerOrange, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium + MetadataTag( + label = "${item.score}", + ) { + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_upvote), + tint = HackerGreen, + contentDescription = "Likes" ) - Text( - text = "•", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall + } + MetadataTag( + label = item.timeLabel, + ) { + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_time_outline), + tint = HackerRed, + contentDescription = "Likes" ) - Text( - text = item.timeLabel, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall + } + Spacer(modifier = Modifier.weight(1f)) + MetadataButton( + label = "${item.commentCount}", + onClick = { onCommentClicked(item) } + ) { + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_chat), + tint = HackerBlue, + contentDescription = "Comments" ) } } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clickable { - onCommentClicked(item) - }, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(R.drawable.ic_chat), - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = "" - ) - Text( - text = "${item.commentCount}", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) - } } } is StoryItem.Loading -> { - Row( + val infiniteTransition = rememberInfiniteTransition("Skeleton") + val skeletonAlpha by infiniteTransition.animateFloat( + initialValue = 0.2f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "Skeleton Alpha" + ) + Column( modifier = modifier .fillMaxWidth() - .heightIn(min = 80.dp) + .heightIn(min = 100.dp) .background(color = MaterialTheme.colorScheme.background) .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 16.dp, alignment = Alignment.CenterHorizontally - ) + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) ) { - Column( + Box( modifier = Modifier - .wrapContentHeight() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + .fillMaxWidth(0.15f) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = HackerOrange.copy(alpha = skeletonAlpha)) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Box( modifier = Modifier - .fillMaxWidth(0.8f) - .height(18.dp) + .fillMaxWidth(0.75f) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(color = Color.LightGray) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) Box( modifier = Modifier - .fillMaxWidth(0.45f) - .height(18.dp) + .fillMaxWidth(0.5f) + .height(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(color = Color.Gray) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) + } + Spacer(modifier = Modifier.height(0.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = HackerGreen.copy(alpha = skeletonAlpha)) + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) + ) + } + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = HackerRed.copy(alpha = skeletonAlpha)) + ) + Box( + modifier = Modifier + .size(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) + ) + } + Spacer(modifier = Modifier.weight(1f)) Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .wrapContentSize() + .clip(CircleShape) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + .padding(vertical = 4.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { Box( modifier = Modifier - .width(30.dp) - .height(14.dp) + .size(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(Color.DarkGray) + .background(color = HackerBlue.copy(alpha = skeletonAlpha)) ) Box( modifier = Modifier - .width(40.dp) - .height(14.dp) + .size(12.dp) .clip(RoundedCornerShape(4.dp)) - .background(HackerOrange) + .background(color = MaterialTheme.colorScheme.onSurface.copy(alpha = skeletonAlpha)) ) } } @@ -373,6 +423,68 @@ fun StoryRow( } } +@Composable +fun ListSeparator( + lineColor: Color, + space: Dp = 0.5.dp +) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(space) + .background(color = lineColor) + ) +} + +@Composable +fun MetadataButton( + label: String, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + backgroundColor: Color = contentColor.copy(alpha = 0.1f), + onClick: () -> Unit = {}, + icon: @Composable () -> Unit, +) { + Row( + modifier = Modifier + .wrapContentSize() + .clip(CircleShape) + .clickable { onClick() } + .background(color = backgroundColor) + .padding(vertical = 4.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + icon() + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = contentColor + ) + } +} + +@Composable +fun MetadataTag( + label: String, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + icon: @Composable () -> Unit +) { + Row( + modifier = Modifier.wrapContentSize(), + horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + icon() + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = contentColor + ) + } +} + @PreviewLightDark @Composable private fun StoryRowPreview() { @@ -493,8 +605,7 @@ private fun FeedSelection( modifier = Modifier .fillMaxWidth() .padding(8.dp) - .clickable( - indication = null, + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onSelected(feedType) }, diff --git a/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt index 84f898cb..b785b6a3 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/ui/theme/Type.kt @@ -1,6 +1,5 @@ package com.emergetools.hackernews.ui.theme -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -19,7 +18,7 @@ val Typography = Typography( titleSmall = TextStyle( fontFamily = plex, fontWeight = FontWeight.Bold, - fontSize = 18.sp + fontSize = 16.sp ), titleMedium = TextStyle( fontFamily = plex, @@ -29,12 +28,12 @@ val Typography = Typography( labelSmall = TextStyle( fontFamily = plex, fontWeight = FontWeight.Normal, - fontSize = 14.sp + fontSize = 12.sp ), labelMedium = TextStyle( fontFamily = plex, fontWeight = FontWeight.Normal, - fontSize = 16.sp + fontSize = 14.sp ), /* Other default text styles to override titleLarge = TextStyle( diff --git a/android/app/src/main/res/drawable/ic_time_outline.xml b/android/app/src/main/res/drawable/ic_time_outline.xml new file mode 100644 index 00000000..9e13a0ad --- /dev/null +++ b/android/app/src/main/res/drawable/ic_time_outline.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_upvote.xml b/android/app/src/main/res/drawable/ic_upvote.xml new file mode 100644 index 00000000..45f227cd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_upvote.xml @@ -0,0 +1,10 @@ + + +