Merge pull request #258 from msfjarvis/comments-screen

This commit is contained in:
Harsh Shandilya 2021-10-06 12:18:46 +05:30 committed by GitHub
commit aa1bd18411
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 318 additions and 87 deletions

View file

@ -20,7 +20,10 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.coreKtx)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.multiplatform.markdown.android)
implementation(libs.copydown)
implementation(libs.dagger.hilt.android)
implementation(libs.sqldelight.extensions.coroutines)
implementation(libs.kotlinx.serialization.json)

View file

@ -1,18 +0,0 @@
package dev.msfjarvis.claw.android.ext
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
/** Convert a [LobstersPost] object returned by the API into a [SavedPost] for persistence. */
fun LobstersPost.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentsUrl = commentsUrl,
submitterName = submitter.username,
submitterAvatarUrl = submitter.avatarUrl,
tags = tags,
)
}

View file

@ -0,0 +1,50 @@
package dev.msfjarvis.claw.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
import kotlinx.coroutines.launch
@Composable
fun HottestPosts(
items: LazyPagingItems<LobstersPost>,
listState: LazyListState,
isPostSaved: suspend (SavedPost) -> Boolean,
toggleSave: suspend (SavedPost) -> Unit,
reloadPosts: () -> Unit,
launchUrl: (String) -> Unit,
viewComments: (String) -> Unit,
modifier: Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val isRefreshing = items.loadState.refresh == LoadState.Loading
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing),
onRefresh = reloadPosts,
) {
if (items.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize())
} else {
NetworkPosts(
items = items,
launchUrl = launchUrl,
listState = listState,
isSaved = isPostSaved,
viewComments = viewComments,
toggleSave = { coroutineScope.launch { toggleSave(it) } },
modifier = Modifier.padding(top = 16.dp).then(modifier),
)
}
}
}

View file

@ -2,9 +2,6 @@ package dev.msfjarvis.claw.android.ui
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
@ -15,7 +12,6 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@ -23,20 +19,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.LoadState
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsPadding
import com.google.accompanist.insets.statusBarsPadding
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.mikepenz.markdown.Markdown
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import kotlinx.coroutines.launch
import io.github.furstenheim.CopyDown
private const val ScrollDelta = 50
@ -46,10 +43,11 @@ fun LobstersApp(
viewModel: ClawViewModel = viewModel(),
urlLauncher: UrlLauncher,
) {
val copydown = remember { CopyDown() }
val systemUiController = rememberSystemUiController()
val scaffoldState = rememberScaffoldState()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val navController = rememberNavController()
var isFabVisible by remember { mutableStateOf(true) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
@ -77,32 +75,40 @@ fun LobstersApp(
systemUiController.setNavigationBarColor(color = Color.Transparent)
}
val items = viewModel.pagerFlow.collectAsLazyPagingItems()
Scaffold(
scaffoldState = scaffoldState,
topBar = { ClawAppBar(modifier = Modifier.statusBarsPadding()) },
floatingActionButton = {
ClawFab(
isFabVisible = isFabVisible,
isFabVisible = isFabVisible && navController.currentDestination?.route == "hottest",
listState = listState,
modifier = Modifier.navigationBarsPadding(),
)
},
) {
val isRefreshing = items.loadState.refresh == LoadState.Loading
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing),
onRefresh = viewModel::reloadPosts,
) {
if (items.itemCount == 0) {
Box(modifier = Modifier.fillMaxSize())
} else {
NetworkPosts(
items = items,
launchUrl = urlLauncher::launch,
listState = listState,
isSaved = viewModel::isPostSaved,
toggleSave = { coroutineScope.launch { viewModel.toggleSave(it) } },
modifier = Modifier.padding(top = 16.dp).nestedScroll(nestedScrollConnection),
) { paddingValues ->
NavHost(navController, startDestination = "hottest") {
composable("hottest") {
HottestPosts(
items,
listState,
viewModel::isPostSaved,
viewModel::toggleSave,
viewModel::reloadPosts,
urlLauncher::launch,
{ navController.navigate("comments/$it") },
Modifier.nestedScroll(nestedScrollConnection),
)
}
composable("comments/{postId}") { backStackEntry ->
CommentsPage(
postId = requireNotNull(backStackEntry.arguments?.getString("postId")),
getDetails = viewModel::getPostComments,
renderMarkdown = { source, modifier ->
val markdown = copydown.convert(source)
Markdown(markdown, modifier = modifier)
},
paddingValues = paddingValues,
)
}
}

View file

@ -13,8 +13,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.items
import dev.msfjarvis.claw.android.ext.toDbModel
import dev.msfjarvis.claw.common.posts.LobstersCard
import dev.msfjarvis.claw.common.posts.toDbModel
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
import kotlinx.coroutines.launch
@ -26,6 +26,7 @@ fun NetworkPosts(
launchUrl: (String) -> Unit,
isSaved: suspend (SavedPost) -> Boolean,
toggleSave: (SavedPost) -> Unit,
viewComments: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
@ -42,7 +43,7 @@ fun NetworkPosts(
post = dbModel,
isSaved = saved,
viewPost = { launchUrl(item.url.ifEmpty { item.commentsUrl }) },
viewComments = { launchUrl(item.commentsUrl) },
viewComments = { viewComments(item.shortId) },
toggleSave = { toggleSave(dbModel) },
modifier = Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp),
)

View file

@ -7,19 +7,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPostDetails
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class ClawViewModel
@Inject
constructor(
api: LobstersApi,
private val api: LobstersApi,
private val repository: SavedPostsRepository,
) : ViewModel() {
var lastPagingSource: LobstersPagingSource? = null
@ -46,6 +49,11 @@ constructor(
}
}
suspend fun getPostComments(postId: String): LobstersPostDetails =
withContext(Dispatchers.IO) {
return@withContext api.getPostDetails(postId)
}
fun reloadPosts() {
lastPagingSource?.invalidate()
}

View file

@ -15,7 +15,7 @@ buildscript {
classpath("com.android.tools:r8:3.1.17-dev")
classpath(kotlin("gradle-plugin", version = kotlinVersion))
classpath(kotlin("serialization", version = kotlinVersion))
classpath("com.android.tools.build:gradle:7.1.0-alpha12")
classpath("com.android.tools.build:gradle:7.1.0-alpha13")
classpath("com.diffplug.spotless:spotless-plugin-gradle:5.15.0")
classpath("com.google.dagger:hilt-android-gradle-plugin:2.39")
}

View file

@ -16,6 +16,7 @@ kotlin {
api(compose.foundation)
api(compose.material)
api(projects.database)
api(projects.model)
}
}
sourceSets["androidMain"].apply {

View file

@ -0,0 +1,7 @@
package dev.msfjarvis.lobsters.ui.comments
sealed class NetworkState {
class Success<T>(val data: T) : NetworkState()
class Error(val message: String) : NetworkState()
object Loading : NetworkState()
}

View file

@ -0,0 +1,79 @@
package dev.msfjarvis.claw.common.comments
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.common.posts.PostDetails
import dev.msfjarvis.claw.common.posts.SubmitterName
import dev.msfjarvis.claw.common.posts.toDbModel
import dev.msfjarvis.claw.model.Comment
import dev.msfjarvis.claw.model.LobstersPostDetails
@Composable
fun CommentsHeader(
postDetails: LobstersPostDetails,
) {
Surface {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PostDetails(
post = postDetails.toDbModel(),
)
}
}
}
@Composable
fun CommentEntry(
comment: Comment,
renderMarkdown: @Composable (comment: String, modifier: Modifier) -> Unit,
) {
val indentLevel = comment.indentLevel.toInt() - 1
Divider(color = Color.Gray.copy(0.4f))
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
CommentTreeColors(indentLevel = indentLevel)
Column(modifier = Modifier.padding(start = 12.dp, end = 8.dp, top = 4.dp, bottom = 4.dp)) {
SubmitterName(
text = "Submitted by ${comment.user.username}",
avatarUrl = "https://lobste.rs/${comment.user.avatarUrl}",
contentDescription = "Submitted by ${comment.user.username}",
)
renderMarkdown(comment.comment, Modifier.padding(top = 8.dp))
}
}
}
@Composable
private fun CommentTreeColors(
indentLevel: Int,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
for (level in 1..indentLevel) {
Box(
modifier =
Modifier.padding(start = (12 + ((level - 1) * 10)).dp)
.fillMaxHeight()
.width(1.dp)
.background(CommentTreeColor[level])
)
}
}
}

View file

@ -24,7 +24,7 @@ object CommentTreeColor {
Color(0xFFFF0097),
Color(0xFF1E7145),
)
private val size = colors.size
val size = colors.size
operator fun get(idx: Int): Color = colors[idx % size]
}

View file

@ -0,0 +1,76 @@
package dev.msfjarvis.claw.common.comments
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.msfjarvis.claw.model.LobstersPostDetails
import dev.msfjarvis.lobsters.ui.comments.NetworkState
@Composable
private fun CommentsPageInternal(
details: LobstersPostDetails,
renderMarkdown: @Composable (comment: String, modifier: Modifier) -> Unit,
bottomPadding: Dp,
) {
LazyColumn(Modifier.padding(bottom = bottomPadding)) {
item { CommentsHeader(postDetails = details) }
item { Spacer(modifier = Modifier.height(8.dp)) }
items(details.comments) { item -> CommentEntry(item, renderMarkdown) }
item { Divider(color = Color.Gray.copy(0.4f)) }
}
}
@Suppress("UNCHECKED_CAST")
@Composable
fun CommentsPage(
postId: String,
getDetails: suspend (String) -> LobstersPostDetails,
renderMarkdown: @Composable (comment: String, modifier: Modifier) -> Unit,
paddingValues: PaddingValues,
) {
var postDetails: NetworkState by remember { mutableStateOf(NetworkState.Loading) }
LaunchedEffect(postId) { postDetails = NetworkState.Success(getDetails(postId)) }
when (postDetails) {
is NetworkState.Success<*> -> {
CommentsPageInternal(
(postDetails as NetworkState.Success<LobstersPostDetails>).data,
renderMarkdown,
paddingValues.calculateBottomPadding(),
)
}
is NetworkState.Error -> TODO("Handle no network scenario")
NetworkState.Loading -> ProgressBar(paddingValues.calculateBottomPadding())
}
}
@Composable
private fun ProgressBar(bottomPadding: Dp) {
Box(
modifier = Modifier.padding(bottom = bottomPadding).fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator(color = MaterialTheme.colors.secondary) }
}

View file

@ -37,18 +37,6 @@ import dev.msfjarvis.claw.common.theme.titleColor
import dev.msfjarvis.claw.common.ui.NetworkImage
import dev.msfjarvis.claw.database.local.SavedPost
val TEST_POST =
SavedPost(
shortId = "zqyydb",
title = "k2k20 hackathon report: Bob Beck on LibreSSL progress",
url = "https://undeadly.org/cgi?action=article;sid=20200921105847",
createdAt = "2020-09-21T07:11:14.000-05:00",
commentsUrl = "https://lobste.rs/s/zqyydb/k2k20_hackathon_report_bob_beck_on",
submitterName = "Vigdis",
submitterAvatarUrl = "https://avatars.githubusercontent.com/u/13348378?v=4",
tags = listOf("openbsd", "linux", "containers", "hack the planet", "no thanks"),
)
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun LobstersCard(
@ -67,16 +55,8 @@ fun LobstersCard(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
PostTitle(
title = post.title,
)
TagRow(
tags = post.tags,
)
SubmitterName(
text = "Submitted by ${post.submitterName}",
avatarUrl = "https://lobste.rs/${post.submitterAvatarUrl}",
contentDescription = "Submitted by ${post.submitterName}",
PostDetails(
post = post,
)
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
@ -98,6 +78,23 @@ fun LobstersCard(
}
}
@Composable
fun PostDetails(
post: SavedPost,
) {
PostTitle(
title = post.title,
)
TagRow(
tags = post.tags,
)
SubmitterName(
text = "Submitted by ${post.submitterName}",
avatarUrl = "https://lobste.rs/${post.submitterAvatarUrl}",
contentDescription = "Submitted by ${post.submitterName}",
)
}
@Composable
fun PostTitle(
title: String,

View file

@ -0,0 +1,31 @@
package dev.msfjarvis.claw.common.posts
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.LobstersPostDetails
fun LobstersPost.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentsUrl = commentsUrl,
submitterName = submitter.username,
submitterAvatarUrl = submitter.avatarUrl,
tags = tags,
)
}
fun LobstersPostDetails.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentsUrl = commentsUrl,
submitterName = submitter.username,
submitterAvatarUrl = submitter.avatarUrl,
tags = tags,
)
}

View file

@ -14,27 +14,13 @@ import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import dev.msfjarvis.claw.common.posts.LobstersCard
import dev.msfjarvis.claw.common.posts.toDbModel
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost
import org.pushingpixels.aurora.component.AuroraVerticalScrollbar
import org.pushingpixels.aurora.skin.ceruleanSkin
import org.pushingpixels.aurora.window.AuroraWindow
fun LobstersPost.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentsUrl = commentsUrl,
submitterName = submitter.username,
submitterAvatarUrl = submitter.avatarUrl,
tags = tags,
)
}
fun main() = application {
val paging = Paging(rememberCoroutineScope())
val items = paging.pagingData.collectAsLazyPagingItems()

View file

@ -23,6 +23,7 @@ androidx-appcompat = "androidx.appcompat:appcompat:1.4.0-beta01"
androidx-browser = "androidx.browser:browser:1.4.0-beta01"
androidx-coreKtx = "androidx.core:core-ktx:1.7.0-beta02"
androidx-lifecycle-compose = "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-rc01"
androidx-navigation-compose = "androidx.navigation:navigation-compose:2.4.0-alpha10"
androidx-paging-compose = "androidx.paging:paging-compose:1.0.0-alpha13"
aurora-component = { module = "org.pushing-pixels:aurora-component", version.ref = "aurora" }
@ -36,6 +37,9 @@ dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref =
dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" }
multiplatform-markdown-android = "com.mikepenz:multiplatform-markdown-renderer-android:0.0.1"
copydown = "io.github.furstenheim:copy_down:1.0"
multiplatform-paging = "dev.msfjarvis.paging:multiplatform-paging:0.4.5-SNAPSHOT"
retrofit-lib = "com.squareup.retrofit2:retrofit:2.9.0"