Implement paging

This is currently breaking swipe to dismiss so it can't stay like this but it's probably better performance-wise
This commit is contained in:
William Brawner 2022-11-11 21:19:40 -07:00
parent d95b63aa49
commit bbbbbeae0d
14 changed files with 178 additions and 62 deletions

View file

@ -19,5 +19,5 @@ suspend fun syncAll(
iconRepository.getFeedIcon(it.feed.id) iconRepository.getFeedIcon(it.feed.id)
} }
} }
entryRepository.getAll(true) entryRepository.fetch()
} }

View file

@ -2,12 +2,14 @@ package com.wbrawner.nanoflux.data.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.wbrawner.nanoflux.network.EntryAndFeedPagingSource
import com.wbrawner.nanoflux.network.repository.* import com.wbrawner.nanoflux.network.repository.*
import com.wbrawner.nanoflux.storage.model.Entry import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import com.wbrawner.nanoflux.syncAll import com.wbrawner.nanoflux.syncAll
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -24,7 +26,12 @@ abstract class EntryListViewModel(
val loading = _loading.asStateFlow() val loading = _loading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null) private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage = _errorMessage.asStateFlow() val errorMessage = _errorMessage.asStateFlow()
abstract val entries: Flow<List<EntryAndFeed>> protected open val entryStatus: EntryStatus? = null
private val pagingSource: EntryAndFeedPagingSource
get() = EntryAndFeedPagingSource(entryRepository, entryStatus)
val entries = Pager(PagingConfig(pageSize = 15)) {
pagingSource
}.flow.cachedIn(viewModelScope)
fun dismissError() { fun dismissError() {
_errorMessage.value = null _errorMessage.value = null
@ -39,9 +46,10 @@ abstract class EntryListViewModel(
return entry.url return entry.url
} }
fun refresh(status: EntryStatus? = null) = viewModelScope.launch(Dispatchers.IO) { fun refresh() = viewModelScope.launch(Dispatchers.IO) {
_loading.emit(true) _loading.emit(true)
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository) syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
pagingSource.invalidate()
_loading.emit(false) _loading.emit(false)
} }
@ -51,9 +59,11 @@ abstract class EntryListViewModel(
} else if (entry.status == Entry.Status.UNREAD) { } else if (entry.status == Entry.Status.UNREAD) {
entryRepository.markEntryRead(entry) entryRepository.markEntryRead(entry)
} }
pagingSource.invalidate()
} }
fun toggleStar(entry: Entry) = viewModelScope.launch(Dispatchers.IO) { fun toggleStar(entry: Entry) = viewModelScope.launch(Dispatchers.IO) {
entryRepository.toggleStar(entry) entryRepository.toggleStar(entry)
pagingSource.invalidate()
} }
} }

View file

@ -36,7 +36,7 @@ class EntryViewModel @Inject constructor(
_errorMessage.value = null _errorMessage.value = null
try { try {
var markedRead = false var markedRead = false
entryRepository.getEntry(id).collect { entryRepository.observeEntry(id).collect {
_entry.value = it _entry.value = it
_loading.value = false _loading.value = false
if (!markedRead) { if (!markedRead) {

View file

@ -1,9 +1,6 @@
package com.wbrawner.nanoflux.data.viewmodel package com.wbrawner.nanoflux.data.viewmodel
import com.wbrawner.nanoflux.network.repository.CategoryRepository import com.wbrawner.nanoflux.network.repository.*
import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.FeedRepository
import com.wbrawner.nanoflux.network.repository.IconRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -22,5 +19,5 @@ class HistoryViewModel @Inject constructor(
iconRepository, iconRepository,
logger logger
) { ) {
override val entries = entryRepository.observeRead() override val entryStatus: EntryStatus = EntryStatus.HISTORY
} }

View file

@ -24,7 +24,7 @@ class MainViewModel @Inject constructor(
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (entryRepository.getCount() == 0L) { if (entryRepository.count() == 0L) {
syncAll(categoryRepository, feedRepository, iconRepository, entryRepository) syncAll(categoryRepository, feedRepository, iconRepository, entryRepository)
} }
} }

View file

@ -1,9 +1,6 @@
package com.wbrawner.nanoflux.data.viewmodel package com.wbrawner.nanoflux.data.viewmodel
import com.wbrawner.nanoflux.network.repository.CategoryRepository import com.wbrawner.nanoflux.network.repository.*
import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.FeedRepository
import com.wbrawner.nanoflux.network.repository.IconRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -22,5 +19,5 @@ class StarredViewModel @Inject constructor(
iconRepository, iconRepository,
logger logger
) { ) {
override val entries = entryRepository.observeStarred() override val entryStatus: EntryStatus = EntryStatus.STARRED
} }

View file

@ -1,9 +1,6 @@
package com.wbrawner.nanoflux.data.viewmodel package com.wbrawner.nanoflux.data.viewmodel
import com.wbrawner.nanoflux.network.repository.CategoryRepository import com.wbrawner.nanoflux.network.repository.*
import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.FeedRepository
import com.wbrawner.nanoflux.network.repository.IconRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -22,5 +19,5 @@ class UnreadViewModel @Inject constructor(
iconRepository, iconRepository,
logger logger
) { ) {
override val entries = entryRepository.observeUnread() override val entryStatus: EntryStatus = EntryStatus.UNREAD
} }

View file

@ -1,5 +1,6 @@
package com.wbrawner.nanoflux.ui package com.wbrawner.nanoflux.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
@ -7,8 +8,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Email
@ -27,6 +29,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemsIndexed
import com.google.accompanist.flowlayout.FlowCrossAxisAlignment import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.SwipeRefresh
@ -44,7 +48,7 @@ import kotlin.math.roundToInt
) )
@Composable @Composable
fun EntryList( fun EntryList(
entries: List<EntryAndFeed>, entries: LazyPagingItems<EntryAndFeed>,
onEntryItemClicked: (entry: Entry) -> Unit, onEntryItemClicked: (entry: Entry) -> Unit,
onFeedClicked: (feed: Feed) -> Unit, onFeedClicked: (feed: Feed) -> Unit,
onCategoryClicked: (category: Category) -> Unit, onCategoryClicked: (category: Category) -> Unit,
@ -61,10 +65,16 @@ fun EntryList(
) { ) {
LazyColumn { LazyColumn {
itemsIndexed(entries, { _, entry -> entry.entry.id }) { index, entry -> itemsIndexed(entries, { _, entry -> entry.entry.id }) { index, entry ->
if (entry == null) {
EntryListPlaceholder()
return@itemsIndexed
}
val dismissState = rememberDismissState() val dismissState = rememberDismissState()
LaunchedEffect(key1 = dismissState.currentValue) { LaunchedEffect(key1 = dismissState.currentValue, key2 = entry.entry.id) {
when (dismissState.currentValue) { when (dismissState.currentValue) {
DismissValue.DismissedToStart -> onToggleReadClicked(entry.entry) DismissValue.DismissedToStart -> {
onToggleReadClicked(entry.entry)
}
DismissValue.DismissedToEnd -> { DismissValue.DismissedToEnd -> {
onStarClicked(entry.entry) onStarClicked(entry.entry)
dismissState.reset() dismissState.reset()
@ -113,7 +123,7 @@ fun EntryList(
onShareClicked = onShareClicked, onShareClicked = onShareClicked,
) )
} }
if (index < entries.lastIndex) { if (index < entries.itemCount - 1) {
Divider() Divider()
} }
} }
@ -121,6 +131,40 @@ fun EntryList(
} }
} }
@Composable
fun EntryListPlaceholder() {
Surface(color = MaterialTheme.colors.surface) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(30.dp),
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
) {}
Surface(
modifier = Modifier
.fillMaxWidth()
.height(30.dp),
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
) {}
Surface(
modifier = Modifier
.fillMaxWidth()
.height(30.dp),
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
) {}
}
}
}
@Composable @Composable
fun EntryListItem( fun EntryListItem(
entry: Entry, entry: Entry,
@ -245,6 +289,7 @@ fun CategoryButton(
@Composable @Composable
@Preview @Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
fun EntryListItem_Preview() { fun EntryListItem_Preview() {
NanofluxApp { NanofluxApp {
EntryListItem( EntryListItem(
@ -310,6 +355,15 @@ fun EntryListItem_Preview() {
} }
} }
@Composable
@Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
fun EntryListPlaceholder_Preview() {
NanofluxApp {
EntryListPlaceholder()
}
}
private const val MILLIS_IN_SECOND = 1000.0 private const val MILLIS_IN_SECOND = 1000.0
private const val MILLIS_IN_MINUTE = 60000.0 private const val MILLIS_IN_MINUTE = 60000.0
private const val MILLIS_IN_HOUR = 3_600_000.0 private const val MILLIS_IN_HOUR = 3_600_000.0

View file

@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.paging.compose.collectAsLazyPagingItems
import com.wbrawner.nanoflux.data.viewmodel.EntryListViewModel import com.wbrawner.nanoflux.data.viewmodel.EntryListViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -21,7 +22,7 @@ fun EntryListScreen(
) { ) {
val loading by entryListViewModel.loading.collectAsState() val loading by entryListViewModel.loading.collectAsState()
val errorMessage by entryListViewModel.errorMessage.collectAsState() val errorMessage by entryListViewModel.errorMessage.collectAsState()
val entries by entryListViewModel.entries.collectAsState(emptyList()) val entries = entryListViewModel.entries.collectAsLazyPagingItems()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
errorMessage?.let { errorMessage?.let {
coroutineScope.launch { coroutineScope.launch {

View file

@ -13,7 +13,9 @@ ktor = "2.1.0"
material = "1.3.0" material = "1.3.0"
maxSdk = "33" maxSdk = "33"
minSdk = "21" minSdk = "21"
paging-compose = "1.0.0-alpha17"
room = "2.4.3" room = "2.4.3"
room-paging = "2.5.0-beta02"
versionCode = "1" versionCode = "1"
versionName = "1.0" versionName = "1.0"
work = "2.7.1" work = "2.7.1"
@ -24,6 +26,7 @@ accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiper
accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" } accompanist-webview = { module = "com.google.accompanist:accompanist-webview", version.ref = "accompanist" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-paging = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" }
material = { module = "com.google.android.material:material", version.ref = "material" } material = { module = "com.google.android.material:material", version.ref = "material" }
compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" } compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compose-compiler" }
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-ui" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose-ui" }

View file

@ -0,0 +1,50 @@
package com.wbrawner.nanoflux.network
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.wbrawner.nanoflux.network.repository.EntryRepository
import com.wbrawner.nanoflux.network.repository.EntryStatus
import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import timber.log.Timber
class EntryAndFeedPagingSource(
private val entryRepository: EntryRepository,
private val entryStatus: EntryStatus? = null
) : PagingSource<Int, EntryAndFeed>() {
init {
Timber.tag("Nanoflux").d("EntryAndFeedPagingSource created")
}
override fun getRefreshKey(state: PagingState<Int, EntryAndFeed>): Int? = state.anchorPosition
?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, EntryAndFeed> {
return try {
val nextPageNumber = params.key ?: 1
val offset = params.loadSize * (nextPageNumber - 1)
val nextKey = if (offset + params.loadSize < entryRepository.count()) {
nextPageNumber + 1
} else {
null
}
Timber.tag("Nanoflux").d("Loading data at page $nextPageNumber")
val loadFunction = when (entryStatus) {
EntryStatus.UNREAD -> entryRepository::loadUnread
EntryStatus.HISTORY -> entryRepository::loadRead
EntryStatus.STARRED -> entryRepository::loadStarred
else -> entryRepository::load
}
LoadResult.Page(
data = loadFunction(params.loadSize, offset),
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
Timber.e("Failed to load page", e)
LoadResult.Error(e)
}
}
}

View file

@ -8,7 +8,10 @@ import com.wbrawner.nanoflux.storage.model.Entry
import com.wbrawner.nanoflux.storage.model.EntryAndFeed import com.wbrawner.nanoflux.storage.model.EntryAndFeed
import io.ktor.http.* import io.ktor.http.*
import io.ktor.util.network.* import io.ktor.util.network.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
@ -20,14 +23,13 @@ class EntryRepository @Inject constructor(
private val entryDao: EntryDao, private val entryDao: EntryDao,
private val logger: Timber.Tree private val logger: Timber.Tree
) { ) {
fun observeRead(): Flow<List<EntryAndFeed>> = entryDao.observeRead() fun observeRead() = entryDao.observeRead()
fun observeStarred(): Flow<List<EntryAndFeed>> = entryDao.observeStarred() fun observeStarred() = entryDao.observeStarred()
fun observeUnread(): Flow<List<EntryAndFeed>> = entryDao.observeUnread() fun observeUnread() = entryDao.observeUnread()
fun getCount(): Long = entryDao.getCount() suspend fun count(): Long = entryDao.count()
suspend fun getAll(fetch: Boolean = false, afterId: Long? = null): List<EntryAndFeed> { suspend fun fetch(afterId: Long? = null) {
if (fetch) {
getEntries { page -> getEntries { page ->
apiService.getEntries( apiService.getEntries(
order = Entry.Order.PUBLISHED_AT, order = Entry.Order.PUBLISHED_AT,
@ -38,23 +40,19 @@ class EntryRepository @Inject constructor(
) )
} }
} }
return entryDao.getAll()
suspend fun load(limit: Int, offset: Int) = entryDao.load(limit, offset)
suspend fun loadRead(limit: Int, offset: Int) = entryDao.loadRead(limit, offset)
suspend fun loadUnread(limit: Int, offset: Int) = entryDao.loadUnread(limit, offset)
suspend fun loadStarred(limit: Int, offset: Int) = entryDao.loadStarred(limit, offset)
suspend fun getEntry(id: Long): EntryAndFeed? = entryDao.get(id)
?: run {
entryDao.insertAll(apiService.getEntry(id))
entryDao.get(id)
} }
suspend fun getAllUnread(fetch: Boolean = false): List<EntryAndFeed> { suspend fun observeEntry(id: Long): Flow<EntryAndFeed> {
if (fetch) {
getEntries { page ->
apiService.getEntries(
offset = 100L * page,
limit = 100,
status = listOf(Entry.Status.UNREAD)
)
}
}
return entryDao.getAllUnread()
}
suspend fun getEntry(id: Long): Flow<EntryAndFeed> {
val entry = entryDao.observe(id) val entry = entryDao.observe(id)
if (entryDao.get(id) == null) { if (entryDao.get(id) == null) {
entryDao.insertAll(apiService.getEntry(id)) entryDao.insertAll(apiService.getEntry(id))

View file

@ -51,6 +51,7 @@ dependencies {
implementation(libs.preference) implementation(libs.preference)
implementation(libs.room.ktx) implementation(libs.room.ktx)
kapt(libs.room.kapt) kapt(libs.room.kapt)
api(libs.androidx.paging)
implementation(libs.bundles.coroutines) implementation(libs.bundles.coroutines)
implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.serialization)
implementation(libs.hilt.android.core) implementation(libs.hilt.android.core)

View file

@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface EntryDao { interface EntryDao {
@Query("SELECT COUNT(*) FROM Entry") @Query("SELECT COUNT(*) FROM Entry")
fun getCount(): Long suspend fun count(): Long
@Transaction @Transaction
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC") @Query("SELECT * FROM Entry ORDER BY publishedAt DESC")
@ -35,12 +35,20 @@ interface EntryDao {
fun observeUnread(): Flow<List<EntryAndFeed>> fun observeUnread(): Flow<List<EntryAndFeed>>
@Transaction @Transaction
@Query("SELECT * FROM Entry ORDER BY publishedAt DESC") @Query("SELECT * FROM Entry ORDER BY publishedAt DESC LIMIT :limit OFFSET :offset")
fun getAll(): List<EntryAndFeed> suspend fun load(limit: Int, offset: Int): List<EntryAndFeed>
@Transaction @Transaction
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY createdAt DESC") @Query("SELECT * FROM Entry WHERE status = \"READ\" ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
fun getAllUnread(): List<EntryAndFeed> suspend fun loadRead(limit: Int, offset: Int): List<EntryAndFeed>
@Transaction
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" ORDER BY createdAt DESC LIMIT :limit OFFSET :offset")
suspend fun loadUnread(limit: Int, offset: Int): List<EntryAndFeed>
@Transaction
@Query("SELECT * FROM Entry WHERE starred = 1 ORDER BY publishedAt DESC LIMIT :limit OFFSET :offset")
suspend fun loadStarred(limit: Int, offset: Int): List<EntryAndFeed>
@Transaction @Transaction
@Query("SELECT * FROM Entry WHERE id = :id") @Query("SELECT * FROM Entry WHERE id = :id")