diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt index 0ea69b15..2e32d732 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/data/HackerNewsSearchClient.kt @@ -15,9 +15,9 @@ private const val BASE_SEARCH_URL = "https://hn.algolia.com/api/v1/" @Serializable data class ItemResponse( val id: Long, - val children: List, @SerialName("created_at") val createdAt: String, + val children: List, val title: String? = null, val author: String? = null, val text: String? = null, diff --git a/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt b/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt index 6534de91..19d5fa88 100644 --- a/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt +++ b/android/app/src/main/java/com/emergetools/hackernews/data/ItemRepository.kt @@ -23,12 +23,6 @@ class ItemRepository( } } - suspend fun getItem(id: ItemId): Item { - return withContext(Dispatchers.IO) { - baseClient.api.getItem(id) - } - } - suspend fun getPage(page: Page): List { return withContext(Dispatchers.IO) { val result = mutableListOf() 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 3d035bf5..d0ed6a82 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 @@ -6,50 +6,72 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.emergetools.hackernews.data.HackerNewsSearchClient import com.emergetools.hackernews.data.ItemResponse +import com.emergetools.hackernews.data.relativeTimeStamp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.time.OffsetDateTime -data class CommentsState( - val title: String, - val author: String, - val points: Int, +sealed interface CommentsState { + val headerState: HeaderState val comments: List -) { - companion object { - val empty = CommentsState( - title = "", - author = "", - points = 0, - comments = emptyList() + + data object Loading: CommentsState { + override val headerState: HeaderState = HeaderState.Loading + override val comments: List = listOf( + CommentState.Loading(level = 0), + CommentState.Loading(level = 0), ) } - val headerState = HeaderState(title, author, points) + data class Content( + val title: String, + val author: String, + val points: Int, + val text: String?, + override val comments: List, + ): CommentsState { + override val headerState = HeaderState.Content(title, author, points, text) + } +} + +sealed interface CommentState { + val level: Int + val children: List + + data class Loading(override val level: Int) : CommentState { + override val children: List = emptyList() + } + + data class Content( + val id: Long, + val author: String, + val content: String, + val timeLabel: String, + override val children: List, + override val level: Int = 0, + ): CommentState } -data class CommentState( - val id: Long, - val author: String, - val content: String, - val children: List, - val level: Int = 0, -) +sealed interface HeaderState { + data object Loading: HeaderState + data class Content( + val title: String, + val author: String, + val points: Int, + val body: String? + ): HeaderState +} -data class HeaderState( - val title: String, - val author: String, - val points: Int -) class CommentsViewModel( private val itemId: Long, private val searchClient: HackerNewsSearchClient ) : ViewModel() { - private val internalState = MutableStateFlow(CommentsState.empty) + private val internalState = MutableStateFlow(CommentsState.Loading) val state = internalState.asStateFlow() init { @@ -60,10 +82,11 @@ class CommentsViewModel( rootComment.createCommentState(0) } internalState.update { - CommentsState( + CommentsState.Content( title = response.title ?: "", author = response.author ?: "", points = response.points ?: 0, + text = response.text, comments = comments ) } @@ -74,13 +97,16 @@ class CommentsViewModel( private fun ItemResponse.createCommentState(level: Int): CommentState { Log.d("Creating CommentState()", "Level: $level, Id: $id") - return CommentState( + return CommentState.Content( id = id, author = author ?: "", content = text ?: "", children = children.map { child -> child.createCommentState(level + 1) }, + timeLabel = relativeTimeStamp( + epochSeconds = OffsetDateTime.parse(createdAt).toInstant().epochSecond + ), level = level ) } 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 d6221ee4..5c75a426 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 @@ -12,6 +12,7 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -55,9 +56,10 @@ fun CommentsScreen(state: CommentsState) { ) } item { + val lineColor = MaterialTheme.colorScheme.onBackground Box(modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .height(16.dp) .drawBehind { val lineStart = Offset(0f, size.center.y) @@ -65,7 +67,7 @@ fun CommentsScreen(state: CommentsState) { drawLine( start = lineStart, end = lineEnd, - color = HNOrange, + color = lineColor, strokeWidth = 4f, cap = StrokeCap.Round, pathEffect = PathEffect.dashPathEffect( @@ -86,22 +88,25 @@ fun CommentsScreen(state: CommentsState) { private fun CommentsScreenPreview() { HackerNewsTheme { CommentsScreen( - state = CommentsState( + state = CommentsState.Content( title = "Show HN: A new HN client for Android", author = "rikinm", points = 69, + text = null, comments = listOf( - CommentState( + CommentState.Content( id = 1, level = 0, author = "rikinm", content = "Hello Child", + timeLabel = "2d ago", children = listOf( - CommentState( + CommentState.Content( id = 2, level = 1, author = "vasantm", content = "Hello Parent", + timeLabel = "1h ago", children = listOf() ) ) @@ -114,21 +119,33 @@ private fun CommentsScreenPreview() { @Preview @Composable -private fun CommentRowPreview() { +private fun CommentsScreenLoadingPreview() { + HackerNewsTheme { + CommentsScreen( + state = CommentsState.Loading + ) + } +} + +@Preview +@Composable +fun CommentRowPreview() { HackerNewsTheme { Column { CommentRow( - state = CommentState( + state = CommentState.Content( id = 1, level = 0, author = "rikinm", content = "Hello Parent", + timeLabel = "2d ago", children = listOf( - CommentState( + CommentState.Content( id = 2, level = 1, author = "vasantm", content = "Hello Child", + timeLabel = "2h ago", children = listOf() ) ) @@ -138,6 +155,18 @@ private fun CommentRowPreview() { } } +@Preview +@Composable +fun CommentRowLoadingPreview() { + HackerNewsTheme { + Column { + CommentRow( + state = CommentState.Loading(level = 0) + ) + } + } +} + @Composable fun CommentRow(state: CommentState) { val startPadding = (state.level * 16).dp @@ -151,44 +180,94 @@ fun CommentRow(state: CommentState) { .padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = state.author, - style = MaterialTheme.typography.labelSmall, - color = HNOrange, - fontWeight = FontWeight.Medium - ) - Text( - "•", - style = MaterialTheme.typography.labelSmall - ) - Text( - "1h ago", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium, - color = Color.Gray - ) - Spacer(modifier = Modifier.weight(1f)) - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Default.ThumbUp, - contentDescription = "upvote" - ) - Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.Default.MoreVert, - contentDescription = "options" - ) - } - Row { - Text( - text = state.content.parseAsHtml(), - style = MaterialTheme.typography.labelSmall - ) + when (state) { + is CommentState.Content -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = state.author, + style = MaterialTheme.typography.labelSmall, + color = HNOrange, + fontWeight = FontWeight.Medium + ) + Text( + "•", + style = MaterialTheme.typography.labelSmall + ) + Text( + text = state.timeLabel, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = Color.Gray + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.ThumbUp, + contentDescription = "upvote" + ) + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.Default.MoreVert, + contentDescription = "options" + ) + } + Row { + Text( + text = state.content.parseAsHtml(), + style = MaterialTheme.typography.labelSmall + ) + } + } + + is CommentState.Loading -> { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .width(40.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(HNOrange) + ) + + Box( + modifier = Modifier + .width(40.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.Gray) + ) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.LightGray) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.LightGray) + ) + Box( + modifier = Modifier + .fillMaxWidth(0.75f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.LightGray) + ) + } + } } } state.children.forEach { child -> @@ -201,10 +280,11 @@ fun CommentRow(state: CommentState) { private fun ItemHeaderPreview() { HackerNewsTheme { ItemHeader( - state = HeaderState( + state = HeaderState.Content( title = "Show HN: A super neat HN client for Android", author = "rikinm", - points = 69 + points = 69, + body = "Hi there" ), modifier = Modifier .fillMaxWidth() @@ -213,39 +293,122 @@ private fun ItemHeaderPreview() { } } +@Preview +@Composable +private fun ItemHeaderLoadingPreview() { + HackerNewsTheme { + ItemHeader( + state = HeaderState.Loading, + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) + } +} + @Composable fun ItemHeader( state: HeaderState, modifier: Modifier = Modifier ) { + Column( modifier = modifier .background(color = MaterialTheme.colorScheme.background) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = state.title, - style = MaterialTheme.typography.titleSmall - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${state.points}", - style = MaterialTheme.typography.labelSmall - ) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall - ) - Text( - text = state.author, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium - ) + when (state) { + is HeaderState.Content -> { + Text( + text = state.title, + style = MaterialTheme.typography.titleSmall + ) + if (state.body != null) { + Text( + text = state.body.parseAsHtml(), + style = MaterialTheme.typography.labelSmall, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${state.points}", + style = MaterialTheme.typography.labelSmall + ) + Text( + text = "•", + style = MaterialTheme.typography.labelSmall + ) + Text( + text = state.author, + 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) + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box( + modifier = Modifier + .width(30.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.DarkGray) + ) + Box( + modifier = Modifier + .width(30.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.onBackground) + ) + } + } } } } \ No newline at end of file