mirror of
https://github.com/msfjarvis/compose-lobsters.git
synced 2024-06-03 04:18:58 +05:30
feat(android): wire up a RemoteMediator to cache newest posts
This commit is contained in:
parent
e2af08aa12
commit
ef0c0ed440
|
@ -0,0 +1,175 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2024 Harsh Shandilya.
|
||||||
|
* Use of this source code is governed by an MIT-style
|
||||||
|
* license that can be found in the LICENSE file or at
|
||||||
|
* https://opensource.org/licenses/MIT.
|
||||||
|
*/
|
||||||
|
package dev.msfjarvis.claw.android.paging
|
||||||
|
|
||||||
|
import androidx.paging.ExperimentalPagingApi
|
||||||
|
import androidx.paging.LoadType
|
||||||
|
import androidx.paging.PagingState
|
||||||
|
import androidx.paging.RemoteMediator
|
||||||
|
import com.slack.eithernet.ApiResult.Failure.ApiFailure
|
||||||
|
import com.slack.eithernet.ApiResult.Failure.HttpFailure
|
||||||
|
import com.slack.eithernet.ApiResult.Failure.NetworkFailure
|
||||||
|
import com.slack.eithernet.ApiResult.Failure.UnknownFailure
|
||||||
|
import com.slack.eithernet.ApiResult.Success
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
|
||||||
|
import dev.msfjarvis.claw.android.viewmodel.ReadPostsRepository
|
||||||
|
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||||
|
import dev.msfjarvis.claw.core.injection.DatabaseDispatcher
|
||||||
|
import dev.msfjarvis.claw.core.injection.IODispatcher
|
||||||
|
import dev.msfjarvis.claw.database.local.NewestPosts
|
||||||
|
import dev.msfjarvis.claw.database.local.NewestPostsKeys
|
||||||
|
import dev.msfjarvis.claw.database.local.NewestPostsKeysQueries
|
||||||
|
import dev.msfjarvis.claw.database.local.NewestPostsQueries
|
||||||
|
import dev.msfjarvis.claw.database.local.SavedPost
|
||||||
|
import dev.msfjarvis.claw.model.LobstersPost
|
||||||
|
import dev.msfjarvis.claw.model.UIPost
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
class CachingRemoteMediator
|
||||||
|
@AssistedInject
|
||||||
|
constructor(
|
||||||
|
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
|
||||||
|
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
@DatabaseDispatcher private val databaseDispatcher: CoroutineDispatcher,
|
||||||
|
private val savedPostsRepository: SavedPostsRepository,
|
||||||
|
private val readPostsRepository: ReadPostsRepository,
|
||||||
|
private val postsDatabase: NewestPostsQueries,
|
||||||
|
private val postKeysDatabase: NewestPostsKeysQueries,
|
||||||
|
) : RemoteMediator<Int, UIPost>() {
|
||||||
|
private val cacheTimeout = 10.seconds
|
||||||
|
private lateinit var savedPosts: List<String>
|
||||||
|
private lateinit var readPosts: List<String>
|
||||||
|
|
||||||
|
override suspend fun initialize(): InitializeAction {
|
||||||
|
savedPosts = savedPostsRepository.savedPosts.first().map(SavedPost::shortId)
|
||||||
|
readPosts = readPostsRepository.readPosts.first()
|
||||||
|
val lastAddition =
|
||||||
|
postsDatabase.getTimeOfLastAddition().executeAsOneOrNull()?.toLongOrNull()?.milliseconds
|
||||||
|
val currentDuration = System.currentTimeMillis().milliseconds
|
||||||
|
return if (lastAddition != null && currentDuration - lastAddition < cacheTimeout) {
|
||||||
|
InitializeAction.SKIP_INITIAL_REFRESH
|
||||||
|
} else {
|
||||||
|
InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(loadType: LoadType, state: PagingState<Int, UIPost>): MediatorResult {
|
||||||
|
val page =
|
||||||
|
when (loadType) {
|
||||||
|
LoadType.REFRESH -> {
|
||||||
|
val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
|
||||||
|
remoteKeys?.nextKey?.minus(1) ?: STARTING_PAGE_INDEX
|
||||||
|
}
|
||||||
|
LoadType.PREPEND -> {
|
||||||
|
val remoteKeys = getRemoteKeyForFirstItem(state)
|
||||||
|
val prevKey =
|
||||||
|
remoteKeys?.prevKey
|
||||||
|
?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
|
||||||
|
prevKey
|
||||||
|
}
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
val remoteKeys = getRemoteKeyForLastItem(state)
|
||||||
|
val nextKey =
|
||||||
|
remoteKeys?.nextKey
|
||||||
|
?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
|
||||||
|
nextKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when (val result = withContext(ioDispatcher) { remoteFetcher.getItemsAtPage(page) }) {
|
||||||
|
is Success -> {
|
||||||
|
val items = result.value
|
||||||
|
val endOfPaginationReached = items.isEmpty()
|
||||||
|
withContext(databaseDispatcher) {
|
||||||
|
if (loadType == LoadType.REFRESH) {
|
||||||
|
postsDatabase.clearCache()
|
||||||
|
postKeysDatabase.clearRemoteKeys()
|
||||||
|
}
|
||||||
|
val prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1
|
||||||
|
val nextKey = if (endOfPaginationReached) null else page + 1
|
||||||
|
val posts =
|
||||||
|
items.map { item ->
|
||||||
|
NewestPosts(
|
||||||
|
shortId = item.shortId,
|
||||||
|
createdAt = item.createdAt,
|
||||||
|
title = item.title,
|
||||||
|
url = item.url,
|
||||||
|
commentCount = item.commentCount,
|
||||||
|
description = item.description,
|
||||||
|
commentsUrl = item.commentsUrl,
|
||||||
|
submitter = item.submitter,
|
||||||
|
tags = item.tags,
|
||||||
|
isRead = readPosts.contains(item.shortId),
|
||||||
|
isSaved = savedPosts.contains(item.shortId),
|
||||||
|
insertTimestamp = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val keys =
|
||||||
|
items.map { item ->
|
||||||
|
NewestPostsKeys(shortId = item.shortId, prevKey = prevKey, nextKey = nextKey)
|
||||||
|
}
|
||||||
|
postsDatabase.transaction { posts.forEach(postsDatabase::addCachedPost) }
|
||||||
|
postKeysDatabase.transaction { keys.forEach(postKeysDatabase::addRemoteKey) }
|
||||||
|
}
|
||||||
|
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||||
|
}
|
||||||
|
is NetworkFailure -> MediatorResult.Error(result.error)
|
||||||
|
is UnknownFailure -> MediatorResult.Error(result.error)
|
||||||
|
is ApiFailure,
|
||||||
|
is HttpFailure -> MediatorResult.Error(IOException("API returned an invalid response"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, UIPost>): NewestPostsKeys? {
|
||||||
|
return state.pages
|
||||||
|
.firstOrNull { it.data.isNotEmpty() }
|
||||||
|
?.data
|
||||||
|
?.firstOrNull()
|
||||||
|
?.let { post ->
|
||||||
|
withContext(databaseDispatcher) {
|
||||||
|
postKeysDatabase.getRemoteKey(post.shortId).executeAsOneOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getRemoteKeyClosestToCurrentPosition(
|
||||||
|
state: PagingState<Int, UIPost>
|
||||||
|
): NewestPostsKeys? {
|
||||||
|
return state.anchorPosition?.let { position ->
|
||||||
|
state.closestItemToPosition(position)?.shortId?.let { shortId ->
|
||||||
|
withContext(databaseDispatcher) {
|
||||||
|
postKeysDatabase.getRemoteKey(shortId).executeAsOneOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, UIPost>): NewestPostsKeys? {
|
||||||
|
return state.pages
|
||||||
|
.lastOrNull { it.data.isNotEmpty() }
|
||||||
|
?.data
|
||||||
|
?.lastOrNull()
|
||||||
|
?.let { post ->
|
||||||
|
withContext(databaseDispatcher) {
|
||||||
|
postKeysDatabase.getRemoteKey(post.shortId).executeAsOneOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(remoteFetcher: RemoteFetcher<LobstersPost>): CachingRemoteMediator
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,14 +44,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@Composable
|
@Composable
|
||||||
fun NetworkPosts(
|
fun NetworkPosts(
|
||||||
lazyPagingItems: LazyPagingItems<UIPost>,
|
lazyPagingItems: LazyPagingItems<UIPost>,
|
||||||
|
refresh: () -> Unit,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
postActions: PostActions,
|
postActions: PostActions,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
|
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
|
||||||
val refreshLoadState = lazyPagingItems.loadState.refresh
|
val refreshLoadState = lazyPagingItems.loadState.refresh
|
||||||
val isRefreshing = refreshLoadState == LoadState.Loading && lazyPagingItems.itemCount == 0
|
val isRefreshing = refreshLoadState == LoadState.Loading
|
||||||
val pullRefreshState = rememberPullRefreshState(isRefreshing, lazyPagingItems::refresh)
|
val pullRefreshState = rememberPullRefreshState(isRefreshing, refresh)
|
||||||
Box(modifier = modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
|
Box(modifier = modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
|
||||||
if (lazyPagingItems.itemCount == 0 && refreshLoadState is LoadState.Error) {
|
if (lazyPagingItems.itemCount == 0 && refreshLoadState is LoadState.Error) {
|
||||||
NetworkError(
|
NetworkError(
|
||||||
|
@ -105,6 +106,7 @@ private fun ListPreview() {
|
||||||
LobstersTheme {
|
LobstersTheme {
|
||||||
NetworkPosts(
|
NetworkPosts(
|
||||||
lazyPagingItems = flow.collectAsLazyPagingItems(),
|
lazyPagingItems = flow.collectAsLazyPagingItems(),
|
||||||
|
refresh = {},
|
||||||
listState = rememberLazyListState(),
|
listState = rememberLazyListState(),
|
||||||
postActions = TEST_POST_ACTIONS,
|
postActions = TEST_POST_ACTIONS,
|
||||||
)
|
)
|
||||||
|
|
|
@ -47,6 +47,7 @@ fun SearchList(
|
||||||
)
|
)
|
||||||
NetworkPosts(
|
NetworkPosts(
|
||||||
lazyPagingItems = lazyPagingItems,
|
lazyPagingItems = lazyPagingItems,
|
||||||
|
refresh = { lazyPagingItems.refresh() },
|
||||||
listState = listState,
|
listState = listState,
|
||||||
postActions = postActions,
|
postActions = postActions,
|
||||||
)
|
)
|
||||||
|
|
|
@ -230,6 +230,7 @@ fun LobstersPostsScreen(
|
||||||
setWebUri("https://lobste.rs/")
|
setWebUri("https://lobste.rs/")
|
||||||
NetworkPosts(
|
NetworkPosts(
|
||||||
lazyPagingItems = hottestPosts,
|
lazyPagingItems = hottestPosts,
|
||||||
|
refresh = { hottestPosts.refresh() },
|
||||||
listState = hottestListState,
|
listState = hottestListState,
|
||||||
postActions = postActions,
|
postActions = postActions,
|
||||||
)
|
)
|
||||||
|
@ -238,6 +239,7 @@ fun LobstersPostsScreen(
|
||||||
setWebUri("https://lobste.rs/")
|
setWebUri("https://lobste.rs/")
|
||||||
NetworkPosts(
|
NetworkPosts(
|
||||||
lazyPagingItems = newestPosts,
|
lazyPagingItems = newestPosts,
|
||||||
|
refresh = { viewModel.refreshNewestPosts() },
|
||||||
listState = newestListState,
|
listState = newestListState,
|
||||||
postActions = postActions,
|
postActions = postActions,
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.ExperimentalPagingApi
|
||||||
import androidx.paging.Pager
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
|
@ -25,6 +26,7 @@ import com.slack.eithernet.ApiResult.Failure
|
||||||
import com.slack.eithernet.ApiResult.Success
|
import com.slack.eithernet.ApiResult.Success
|
||||||
import com.squareup.anvil.annotations.optional.ForScope
|
import com.squareup.anvil.annotations.optional.ForScope
|
||||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||||
|
import dev.msfjarvis.claw.android.paging.CachingRemoteMediator
|
||||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
|
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
|
||||||
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
|
||||||
|
@ -64,10 +66,12 @@ constructor(
|
||||||
private val dataTransferRepository: DataTransferRepository,
|
private val dataTransferRepository: DataTransferRepository,
|
||||||
private val pagingSourceFactory: LobstersPagingSource.Factory,
|
private val pagingSourceFactory: LobstersPagingSource.Factory,
|
||||||
private val searchPagingSourceFactory: SearchPagingSource.Factory,
|
private val searchPagingSourceFactory: SearchPagingSource.Factory,
|
||||||
|
cachingMediatorFactory: CachingRemoteMediator.Factory,
|
||||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||||
@ForScope(ApplicationScope::class) context: Context,
|
@ForScope(ApplicationScope::class) context: Context,
|
||||||
) : AndroidViewModel(context as Application) {
|
) : AndroidViewModel(context as Application) {
|
||||||
|
private var cachingPagingSource: LobstersPagingSource? = null
|
||||||
val hottestPosts =
|
val hottestPosts =
|
||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||||
|
@ -76,11 +80,15 @@ constructor(
|
||||||
)
|
)
|
||||||
.flow
|
.flow
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
val newestPosts =
|
val newestPosts =
|
||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(pageSize = PAGE_SIZE),
|
config = PagingConfig(pageSize = PAGE_SIZE),
|
||||||
|
remoteMediator = cachingMediatorFactory.create(api::getNewestPosts),
|
||||||
initialKey = STARTING_PAGE_INDEX,
|
initialKey = STARTING_PAGE_INDEX,
|
||||||
pagingSourceFactory = { pagingSourceFactory.create(api::getNewestPosts) },
|
pagingSourceFactory = {
|
||||||
|
pagingSourceFactory.create(api::getNewestPosts).also { cachingPagingSource = it }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.flow
|
.flow
|
||||||
.cachedIn(viewModelScope)
|
.cachedIn(viewModelScope)
|
||||||
|
@ -126,6 +134,10 @@ constructor(
|
||||||
.toImmutableMap()
|
.toImmutableMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshNewestPosts() {
|
||||||
|
cachingPagingSource?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleSave(post: UIPost) {
|
fun toggleSave(post: UIPost) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
savedPostsRepository.toggleSave(post)
|
savedPostsRepository.toggleSave(post)
|
||||||
|
|
Loading…
Reference in New Issue
Block a user