refactor: lazily query saved and read state in UI

Having this always be read from the UI avoids values going stale inside data models

Fixes #641
This commit is contained in:
Harsh Shandilya 2024-08-28 13:11:42 +05:30
parent 6f424ae2d5
commit 8651a4f66b
12 changed files with 56 additions and 100 deletions

View file

@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Opening posts you have previously seen will show the number of new comments since last visit - Opening posts you have previously seen will show the number of new comments since last visit
### Fixed
- Saving posts no longer triggers a page refresh that invalidates scroll position
### Changed ### Changed
- Change submitter text to 'authored' when applicable - Change submitter text to 'authored' when applicable

View file

@ -14,16 +14,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dev.msfjarvis.claw.android.ui.toError import dev.msfjarvis.claw.android.ui.toError
import dev.msfjarvis.claw.android.viewmodel.ReadPostsRepository
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
import dev.msfjarvis.claw.core.injection.IODispatcher import dev.msfjarvis.claw.core.injection.IODispatcher
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.model.LobstersPost import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.toUIPost import dev.msfjarvis.claw.model.toUIPost
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class LobstersPagingSource class LobstersPagingSource
@ -31,33 +27,14 @@ class LobstersPagingSource
constructor( constructor(
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>, @Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
@IODispatcher private val ioDispatcher: CoroutineDispatcher, @IODispatcher private val ioDispatcher: CoroutineDispatcher,
private val savedPostsRepository: SavedPostsRepository,
private val readPostsRepository: ReadPostsRepository,
) : PagingSource<Int, UIPost>() { ) : PagingSource<Int, UIPost>() {
private lateinit var savedPosts: List<String>
private lateinit var readPosts: List<String>
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
if (!::savedPosts.isInitialized) {
savedPosts = savedPostsRepository.savedPosts.first().map(SavedPost::shortId)
}
if (!::readPosts.isInitialized) {
readPosts = readPostsRepository.readPosts.first()
}
val page = params.key ?: STARTING_PAGE_INDEX val page = params.key ?: STARTING_PAGE_INDEX
return when (val result = withContext(ioDispatcher) { remoteFetcher.getItemsAtPage(page) }) { return when (val result = withContext(ioDispatcher) { remoteFetcher.getItemsAtPage(page) }) {
is Success -> is Success ->
LoadResult.Page( LoadResult.Page(
itemsBefore = (page - 1) * PAGE_SIZE, itemsBefore = (page - 1) * PAGE_SIZE,
data = data = result.value.map(LobstersPost::toUIPost),
result.value.map {
it
.toUIPost()
.copy(
isSaved = savedPosts.contains(it.shortId),
isRead = readPosts.contains(it.shortId),
)
},
prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1, prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
nextKey = page + 1, nextKey = page + 1,
) )

View file

@ -15,16 +15,13 @@ import dagger.assisted.AssistedInject
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
import dev.msfjarvis.claw.android.ui.toError import dev.msfjarvis.claw.android.ui.toError
import dev.msfjarvis.claw.android.viewmodel.ReadPostsRepository
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
import dev.msfjarvis.claw.api.LobstersSearchApi import dev.msfjarvis.claw.api.LobstersSearchApi
import dev.msfjarvis.claw.core.injection.IODispatcher import dev.msfjarvis.claw.core.injection.IODispatcher
import dev.msfjarvis.claw.database.local.SavedPost import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.toUIPost import dev.msfjarvis.claw.model.toUIPost
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
@ -39,19 +36,8 @@ constructor(
private val searchApi: LobstersSearchApi, private val searchApi: LobstersSearchApi,
@Assisted private val queryProvider: () -> String, @Assisted private val queryProvider: () -> String,
@IODispatcher private val ioDispatcher: CoroutineDispatcher, @IODispatcher private val ioDispatcher: CoroutineDispatcher,
private val savedPostsRepository: SavedPostsRepository,
private val readPostsRepository: ReadPostsRepository,
) : PagingSource<Int, UIPost>() { ) : PagingSource<Int, UIPost>() {
private lateinit var savedPosts: List<String>
private lateinit var readPosts: List<String>
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, UIPost> {
if (!::savedPosts.isInitialized) {
savedPosts = savedPostsRepository.savedPosts.first().map(SavedPost::shortId)
}
if (!::readPosts.isInitialized) {
readPosts = readPostsRepository.readPosts.first()
}
val searchQuery = queryProvider() val searchQuery = queryProvider()
// If there is no query, we don't need to call the API at all. // If there is no query, we don't need to call the API at all.
if (searchQuery.isEmpty()) { if (searchQuery.isEmpty()) {
@ -66,15 +52,7 @@ constructor(
val nextKey = if (result.value.isEmpty()) null else page + 1 val nextKey = if (result.value.isEmpty()) null else page + 1
LoadResult.Page( LoadResult.Page(
itemsBefore = (page - 1) * PAGE_SIZE, itemsBefore = (page - 1) * PAGE_SIZE,
data = data = result.value.map(LobstersPost::toUIPost),
result.value.map {
it
.toUIPost()
.copy(
isSaved = savedPosts.contains(it.shortId),
isRead = readPosts.contains(it.shortId),
)
},
prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1, prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
nextKey = nextKey, nextKey = nextKey,
) )

View file

@ -70,6 +70,10 @@ fun rememberPostActions(
context.startActivity(shareIntent) context.startActivity(shareIntent)
} }
override fun isPostRead(post: UIPost): Boolean = viewModel.isPostRead(post)
override fun isPostSaved(post: UIPost): Boolean = viewModel.isPostSaved(post)
override suspend fun getComments(postId: String): UIPost { override suspend fun getComments(postId: String): UIPost {
return viewModel.getPostComments(postId) return viewModel.getPostComments(postId)
} }

View file

@ -54,7 +54,7 @@ fun DatabasePosts(
items.forEach { (month, posts) -> items.forEach { (month, posts) ->
stickyHeader(contentType = "month-header") { MonthHeader(label = month) } stickyHeader(contentType = "month-header") { MonthHeader(label = month) }
items(items = posts, key = { it.shortId }, contentType = { "LobstersItem" }) { item -> items(items = posts, key = { it.shortId }, contentType = { "LobstersItem" }) { item ->
LobstersListItem(item = item, refresh = {}, postActions = postActions) LobstersListItem(item = item, postActions = postActions)
HorizontalDivider() HorizontalDivider()
} }
} }

View file

@ -20,12 +20,7 @@ import me.saket.swipe.SwipeAction
import me.saket.swipe.SwipeableActionsBox import me.saket.swipe.SwipeableActionsBox
@Composable @Composable
fun LobstersListItem( fun LobstersListItem(item: UIPost, postActions: PostActions, modifier: Modifier = Modifier) {
item: UIPost,
postActions: PostActions,
refresh: () -> Unit,
modifier: Modifier = Modifier,
) {
val commentsAction = val commentsAction =
SwipeAction( SwipeAction(
icon = rememberVectorPainter(Icons.AutoMirrored.Filled.Reply), icon = rememberVectorPainter(Icons.AutoMirrored.Filled.Reply),
@ -39,6 +34,6 @@ fun LobstersListItem(
onSwipe = { postActions.share(item) }, onSwipe = { postActions.share(item) },
) )
SwipeableActionsBox(startActions = listOf(shareAction), endActions = listOf(commentsAction)) { SwipeableActionsBox(startActions = listOf(shareAction), endActions = listOf(commentsAction)) {
LobstersCard(post = item, postActions = postActions, refresh = refresh, modifier = modifier) LobstersCard(post = item, postActions = postActions, modifier = modifier)
} }
} }

View file

@ -73,11 +73,7 @@ fun NetworkPosts(
) { index -> ) { index ->
val item = lazyPagingItems[index] val item = lazyPagingItems[index]
if (item != null) { if (item != null) {
LobstersListItem( LobstersListItem(item = item, postActions = postActions)
item = item,
postActions = postActions,
refresh = { lazyPagingItems.refresh() },
)
HorizontalDivider() HorizontalDivider()
} }
} }

View file

@ -133,6 +133,14 @@ constructor(
} }
} }
fun isPostRead(post: UIPost): Boolean {
return _readPosts.contains(post.shortId)
}
fun isPostSaved(post: UIPost): Boolean {
return _savedPosts.contains(post.shortId)
}
suspend fun getPostComments(postId: String) = suspend fun getPostComments(postId: String) =
withContext(ioDispatcher) { withContext(ioDispatcher) {
when (val result = api.getPostDetails(postId)) { when (val result = api.getPostDetails(postId)) {

View file

@ -16,6 +16,7 @@ import dev.msfjarvis.claw.model.toSavedPost
import io.github.aakira.napier.Napier import io.github.aakira.napier.Napier
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class SavedPostsRepository class SavedPostsRepository
@ -27,7 +28,7 @@ constructor(
val savedPosts = savedPostQueries.selectAllPosts().asFlow().mapToList(dbDispatcher) val savedPosts = savedPostQueries.selectAllPosts().asFlow().mapToList(dbDispatcher)
suspend fun toggleSave(post: UIPost) { suspend fun toggleSave(post: UIPost) {
if (post.isSaved) { if (savedPosts.firstOrNull().orEmpty().any { it.shortId == post.shortId }) {
Napier.d(tag = TAG) { "Removing post: ${post.shortId}" } Napier.d(tag = TAG) { "Removing post: ${post.shortId}" }
withContext(dbDispatcher) { savedPostQueries.deletePost(post.shortId) } withContext(dbDispatcher) { savedPostQueries.deletePost(post.shortId) }
} else { } else {

View file

@ -38,6 +38,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -60,23 +61,14 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
fun LobstersCard( fun LobstersCard(post: UIPost, postActions: PostActions, modifier: Modifier = Modifier) {
post: UIPost, val readState by remember(post.shortId) { derivedStateOf { postActions.isPostRead(post) } }
postActions: PostActions, val savedState by remember(post.shortId) { derivedStateOf { postActions.isPostSaved(post) } }
refresh: () -> Unit,
modifier: Modifier = Modifier,
) {
var localReadState by remember(post) { mutableStateOf(post.isRead) }
var localSavedState by remember(post) { mutableStateOf(post.isSaved) }
Box( Box(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable { postActions.viewPost(post.shortId, post.url, post.commentsUrl) }
postActions.viewPost(post.shortId, post.url, post.commentsUrl)
localReadState = true
refresh()
}
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.padding(start = 8.dp, top = 2.dp, bottom = 2.dp) .padding(start = 8.dp, top = 2.dp, bottom = 2.dp)
) { ) {
@ -86,7 +78,7 @@ fun LobstersCard(
) { ) {
PostDetails( PostDetails(
post = post, post = post,
isRead = localReadState, isRead = { readState },
singleLineTitle = true, singleLineTitle = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@ -94,25 +86,14 @@ fun LobstersCard(
modifier = Modifier.wrapContentHeight(), modifier = Modifier.wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
SaveButton( SaveButton(isSaved = { savedState }, onClick = { postActions.toggleSave(post) })
isSaved = localSavedState,
modifier =
Modifier.clickable(role = Role.Button) {
localSavedState = !localSavedState
postActions.toggleSave(post)
},
)
HorizontalDivider(modifier = Modifier.width(48.dp)) HorizontalDivider(modifier = Modifier.width(48.dp))
CommentsButton( CommentsButton(
commentCount = post.commentCount, commentCount = post.commentCount,
modifier = modifier =
Modifier.clickable( Modifier.clickable(
role = Role.Button, role = Role.Button,
onClick = { onClick = { postActions.viewComments(post.shortId) },
postActions.viewComments(post.shortId)
localReadState = true
refresh()
},
), ),
) )
} }
@ -123,12 +104,12 @@ fun LobstersCard(
@Composable @Composable
fun PostDetails( fun PostDetails(
post: UIPost, post: UIPost,
isRead: Boolean, isRead: () -> Boolean,
singleLineTitle: Boolean, singleLineTitle: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
PostTitle(title = post.title, isRead = isRead, singleLineTitle = singleLineTitle) PostTitle(title = post.title, isRead = isRead(), singleLineTitle = singleLineTitle)
TagRow(tags = post.tags.toImmutableList()) TagRow(tags = post.tags.toImmutableList())
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Submitter( Submitter(
@ -189,14 +170,19 @@ internal fun Submitter(
} }
@Composable @Composable
private fun SaveButton(isSaved: Boolean, modifier: Modifier = Modifier) { private fun SaveButton(isSaved: () -> Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
Crossfade(targetState = isSaved, label = "save-button") { saved -> var localSavedState by remember { mutableStateOf(isSaved()) }
Crossfade(targetState = localSavedState, label = "save-button") { saved ->
Box(modifier = modifier.minimumInteractiveComponentSize()) { Box(modifier = modifier.minimumInteractiveComponentSize()) {
Icon( Icon(
imageVector = if (saved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, imageVector = if (saved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
contentDescription = if (saved) "Remove from saved posts" else "Add to saved posts", contentDescription = if (saved) "Remove from saved posts" else "Add to saved posts",
modifier = Modifier.align(Alignment.Center).testTag("save_button"), modifier =
Modifier.align(Alignment.Center).testTag("save_button").clickable(role = Role.Button) {
onClick()
localSavedState = !localSavedState
},
) )
} }
} }
@ -266,6 +252,14 @@ val TEST_POST_ACTIONS =
override fun share(post: UIPost) {} override fun share(post: UIPost) {}
override fun isPostRead(post: UIPost): Boolean {
return true
}
override fun isPostSaved(post: UIPost): Boolean {
return true
}
override suspend fun getComments(postId: String): UIPost { override suspend fun getComments(postId: String): UIPost {
return UIPost( return UIPost(
shortId = "ooga", shortId = "ooga",
@ -298,12 +292,10 @@ val TEST_POST =
submitter = "Haki", submitter = "Haki",
tags = listOf("databases", "apis"), tags = listOf("databases", "apis"),
description = "", description = "",
isSaved = true,
isRead = true,
) )
@ThemePreviews @ThemePreviews
@Composable @Composable
private fun LobstersCardPreview() { private fun LobstersCardPreview() {
LobstersTheme { LobstersCard(post = TEST_POST, postActions = TEST_POST_ACTIONS, refresh = {}) } LobstersTheme { LobstersCard(post = TEST_POST, postActions = TEST_POST_ACTIONS) }
} }

View file

@ -22,6 +22,10 @@ interface PostActions {
fun share(post: UIPost) fun share(post: UIPost)
fun isPostRead(post: UIPost): Boolean
fun isPostSaved(post: UIPost): Boolean
suspend fun getComments(postId: String): UIPost suspend fun getComments(postId: String): UIPost
suspend fun getLinkMetadata(url: String): LinkMetadata suspend fun getLinkMetadata(url: String): LinkMetadata

View file

@ -27,8 +27,6 @@ data class UIPost(
@SerialName("submitter_user") val submitter: String, @SerialName("submitter_user") val submitter: String,
val tags: List<String>, val tags: List<String>,
val comments: List<Comment> = emptyList(), val comments: List<Comment> = emptyList(),
val isSaved: Boolean = false,
val isRead: Boolean = false,
val userIsAuthor: Boolean = false, val userIsAuthor: Boolean = false,
) { ) {
@KonvertFrom( @KonvertFrom(
@ -37,7 +35,6 @@ data class UIPost(
[ [
Mapping(target = "submitter", expression = "it.submitterName"), Mapping(target = "submitter", expression = "it.submitterName"),
Mapping(target = "commentCount", expression = "it.commentCount ?: 0"), Mapping(target = "commentCount", expression = "it.commentCount ?: 0"),
Mapping(target = "isSaved", expression = "true"),
], ],
) )
companion object companion object