Implement androidx Paging and unify TimeLineViewModel state

This commit is contained in:
Shinokuni 2023-08-23 23:03:19 +02:00
parent 16ed0ef05e
commit 36f768044a
8 changed files with 153 additions and 141 deletions

View File

@ -3,10 +3,10 @@ package com.readrops.app.compose.timelime
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.Tab
@ -48,27 +49,22 @@ object TimelineTab : Tab {
override val options: TabOptions override val options: TabOptions
@Composable @Composable
get() { get() = TabOptions(
return TabOptions(
index = 1u, index = 1u,
title = "Timeline", title = "Timeline",
) )
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
override fun Content() { override fun Content() {
val viewModel = getViewModel<TimelineViewModel>() val viewModel = getViewModel<TimelineViewModel>()
val state by viewModel.timelineState.collectAsStateWithLifecycle() val state by viewModel.timelineState.collectAsStateWithLifecycle()
val drawerState by viewModel.drawerState.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
val swipeState = rememberSwipeRefreshState(isRefreshing) val swipeState = rememberSwipeRefreshState(state.isRefreshing)
val drawerUIState = rememberDrawerState( val drawerState = rememberDrawerState(
initialValue = DrawerValue.Closed, initialValue = DrawerValue.Closed,
confirmStateChange = { confirmStateChange = {
if (it == DrawerValue.Closed) { if (it == DrawerValue.Closed) {
@ -82,23 +78,23 @@ object TimelineTab : Tab {
) )
BackHandler( BackHandler(
enabled = drawerState.isOpen, enabled = state.isDrawerOpen,
onBack = { viewModel.closeDrawer() } onBack = { viewModel.closeDrawer() }
) )
LaunchedEffect(drawerState.isOpen) { LaunchedEffect(state.isDrawerOpen) {
if (drawerState.isOpen) { if (state.isDrawerOpen) {
drawerUIState.open() drawerState.open()
} else { } else {
drawerUIState.close() drawerState.close()
} }
} }
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerUIState, drawerState = drawerState,
drawerContent = { drawerContent = {
TimelineDrawer( TimelineDrawer(
state = drawerState, state = state,
onClickDefaultItem = { onClickDefaultItem = {
viewModel.updateDrawerDefaultItem(it) viewModel.updateDrawerDefaultItem(it)
}, },
@ -156,8 +152,8 @@ object TimelineTab : Tab {
onRefresh = { viewModel.refreshTimeline() }, onRefresh = { viewModel.refreshTimeline() },
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) { ) {
when (state) { when (val itemState = state.items) {
is TimelineState.Loading -> { is ItemState.Loading -> {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@ -167,25 +163,22 @@ object TimelineTab : Tab {
} }
} }
is TimelineState.Error -> { is ItemState.Error -> TODO()
is ItemState.Loaded -> {
val items = itemState.items.collectAsLazyPagingItems()
}
is TimelineState.Loaded -> {
val items = (state as TimelineState.Loaded).items
if (items.isNotEmpty()) {
LazyColumn( LazyColumn(
state = scrollState, state = scrollState,
contentPadding = PaddingValues(vertical = MaterialTheme.spacing.shortSpacing),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.shortSpacing) verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.shortSpacing)
) { ) {
items( items(
items = items, count = items.itemCount,
key = { it.item.id }, key = { items[it]!!.item.id },
contentType = { "item_with_feed" } contentType = { "item_with_feed" }
) { itemWithFeed -> ) { itemCount ->
TimelineItem( TimelineItem(
itemWithFeed = itemWithFeed, itemWithFeed = items[itemCount]!!,
onClick = { navigator.push(ItemScreen()) }, onClick = { navigator.push(ItemScreen()) },
onFavorite = {}, onFavorite = {},
onReadLater = {}, onReadLater = {},
@ -193,9 +186,6 @@ object TimelineTab : Tab {
) )
} }
} }
} else {
NoItemPlaceholder()
}
} }
} }
} }

View File

@ -2,23 +2,26 @@ package com.readrops.app.compose.timelime
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.readrops.app.compose.base.TabViewModel import com.readrops.app.compose.base.TabViewModel
import com.readrops.app.compose.repositories.GetFoldersWithFeeds import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.timelime.drawer.DrawerDefaultItemsSelection
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
import com.readrops.db.filters.FilterType
import com.readrops.db.pojo.ItemWithFeed import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.queries.ItemsQueryBuilder import com.readrops.db.queries.ItemsQueryBuilder
import com.readrops.db.queries.QueryFilters import com.readrops.db.queries.QueryFilters
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -29,108 +32,128 @@ class TimelineViewModel(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : TabViewModel(database) { ) : TabViewModel(database) {
private val _timelineState = MutableStateFlow<TimelineState>(TimelineState.Loading) private val _timelineState = MutableStateFlow(TimelineState())
val timelineState = _timelineState.asStateFlow() val timelineState = _timelineState.asStateFlow()
private var _isRefreshing = MutableStateFlow(false) private val filters = MutableStateFlow(_timelineState.value.filters)
val isRefreshing = _isRefreshing.asStateFlow()
private val _drawerState = MutableStateFlow(DrawerState())
val drawerState = _drawerState.asStateFlow()
init { init {
viewModelScope.launch(dispatcher) { viewModelScope.launch(dispatcher) {
accountEvent.consumeAsFlow().collectLatest { account -> combine(
val query = ItemsQueryBuilder.buildItemsQuery(QueryFilters(accountId = account.id)) accountEvent.consumeAsFlow(),
filters
) { account, filters ->
Pair(account, filters)
}.collectLatest { (account, filters) ->
val query = ItemsQueryBuilder.buildItemsQuery(filters.copy(accountId = account.id))
val items = async { _timelineState.update {
database.newItemDao().selectAll(query)
.catch { _timelineState.value = TimelineState.Error(Exception(it)) }
.collect {
_timelineState.value = TimelineState.Loaded(it)
}
}
val drawer = async {
_drawerState.update {
it.copy( it.copy(
foldersAndFeeds = getFoldersWithFeeds.get(account.id) foldersAndFeeds = getFoldersWithFeeds.get(account.id),
items = ItemState.Loaded(
items = Pager(
config = PagingConfig(
pageSize = 100,
prefetchDistance = 150
),
pagingSourceFactory = {
database.newItemDao().selectAll(query)
},
).flow
.cachedIn(viewModelScope)
)
) )
} }
} }
awaitAll(items, drawer)
}
} }
} }
fun refreshTimeline() { fun refreshTimeline() {
_isRefreshing.value = true _timelineState.update { it.copy(isRefreshing = true) }
viewModelScope.launch(dispatcher) { viewModelScope.launch(dispatcher) {
repository?.synchronize(null) { repository?.synchronize(null) {
} }
_isRefreshing.value = false _timelineState.update { it.copy(isRefreshing = false) }
} }
} }
fun openDrawer() { fun openDrawer() {
_drawerState.update { it.copy(isOpen = true) } _timelineState.update { it.copy(isDrawerOpen = true) }
} }
fun closeDrawer() { fun closeDrawer() {
_drawerState.update { it.copy(isOpen = false) } _timelineState.update { it.copy(isDrawerOpen = false) }
} }
fun updateDrawerDefaultItem(selection: DrawerDefaultItemsSelection) { fun updateDrawerDefaultItem(selection: FilterType) {
_drawerState.update { _timelineState.update {
it.copy( it.copy(
isOpen = false, filters = updateFilters {
selection = selection, it.filters.copy(
selectedFolderId = 0, filterType = selection
selectedFeedId = 0, )
},
isDrawerOpen = false
) )
} }
} }
fun updateDrawerFolderSelection(folderId: Int) { fun updateDrawerFolderSelection(folderId: Int) {
_drawerState.update { _timelineState.update {
it.copy( it.copy(
isOpen = false, filters = updateFilters {
selectedFolderId = folderId, it.filters.copy(
selectedFeedId = 0 filterType = FilterType.FOLDER_FILER,
filterFolderId = folderId,
filterFeedId = 0
)
},
isDrawerOpen = false
) )
} }
} }
fun updateDrawerFeedSelection(feedId: Int) { fun updateDrawerFeedSelection(feedId: Int) {
_drawerState.update { _timelineState.update {
it.copy( it.copy(
isOpen = false, filters = updateFilters {
selectedFeedId = feedId, it.filters.copy(
selectedFolderId = 0 filterType = FilterType.FEED_FILTER,
filterFeedId = feedId,
filterFolderId = 0
)
},
isDrawerOpen = false
) )
} }
} }
}
sealed class TimelineState { private fun updateFilters(block: () -> QueryFilters): QueryFilters {
object Loading : TimelineState() val filter = block()
filters.update { filter }
@Immutable return filter
data class Error(val exception: Exception) : TimelineState() }
@Immutable
data class Loaded(val items: List<ItemWithFeed>) : TimelineState()
} }
@Immutable @Immutable
data class DrawerState( data class TimelineState(
val isOpen: Boolean = false, val isRefreshing: Boolean = false,
val selection: DrawerDefaultItemsSelection = DrawerDefaultItemsSelection.ARTICLES, val isDrawerOpen: Boolean = false,
val selectedFolderId: Int = 0, val filters: QueryFilters = QueryFilters(),
val selectedFeedId: Int = 0, val foldersAndFeeds: Map<Folder?, List<Feed>> = emptyMap(),
val foldersAndFeeds: Map<Folder?, List<Feed>> = emptyMap() val items: ItemState = ItemState.Loading
) )
sealed class ItemState {
@Immutable
object Loading : ItemState()
@Immutable
data class Error(val exception: Exception) : ItemState()
@Immutable
data class Loaded(val items: Flow<PagingData<ItemWithFeed>>) : ItemState()
}

View File

@ -23,20 +23,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.readrops.app.compose.R import com.readrops.app.compose.R
import com.readrops.app.compose.timelime.DrawerState import com.readrops.app.compose.timelime.TimelineState
import com.readrops.app.compose.util.theme.spacing import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.filters.FilterType
enum class DrawerDefaultItemsSelection {
ARTICLES,
NEW,
FAVORITES,
READ_LATER
}
@Composable @Composable
fun TimelineDrawer( fun TimelineDrawer(
state: DrawerState, state: TimelineState,
onClickDefaultItem: (DrawerDefaultItemsSelection) -> Unit, onClickDefaultItem: (FilterType) -> Unit,
onFolderClick: (Int) -> Unit, onFolderClick: (Int) -> Unit,
onFeedClick: (Int) -> Unit, onFeedClick: (Int) -> Unit,
) { ) {
@ -50,7 +44,7 @@ fun TimelineDrawer(
Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing)) Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing))
DrawerDefaultItems( DrawerDefaultItems(
selectedItem = state.selection, selectedItem = state.filters.filterType,
onClick = { onClickDefaultItem(it) } onClick = { onClickDefaultItem(it) }
) )
@ -78,10 +72,10 @@ fun TimelineDrawer(
badge = { badge = {
Text(folderEntry.value.sumOf { it.unreadCount }.toString()) Text(folderEntry.value.sumOf { it.unreadCount }.toString())
}, },
selected = state.selectedFolderId == folder.id, selected = state.filters.filterFolderId == folder.id,
onClick = { onFolderClick(folder.id) }, onClick = { onFolderClick(folder.id) },
feeds = folderEntry.value, feeds = folderEntry.value,
selectedFeed = state.selectedFeedId, selectedFeed = state.filters.filterFeedId,
onFeedClick = { onFeedClick(it) }, onFeedClick = { onFeedClick(it) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
@ -106,7 +100,7 @@ fun TimelineDrawer(
) )
}, },
badge = { Text(feed.unreadCount.toString()) }, badge = { Text(feed.unreadCount.toString()) },
selected = feed.id == state.selectedFeedId, selected = feed.id == state.filters.filterFeedId,
onClick = { onFeedClick(feed.id) }, onClick = { onFeedClick(feed.id) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
@ -119,8 +113,8 @@ fun TimelineDrawer(
@Composable @Composable
fun DrawerDefaultItems( fun DrawerDefaultItems(
selectedItem: DrawerDefaultItemsSelection, selectedItem: FilterType,
onClick: (DrawerDefaultItemsSelection) -> Unit, onClick: (FilterType) -> Unit,
) { ) {
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Articles") }, label = { Text("Articles") },
@ -130,8 +124,8 @@ fun DrawerDefaultItems(
contentDescription = null contentDescription = null
) )
}, },
selected = selectedItem == DrawerDefaultItemsSelection.ARTICLES, selected = selectedItem == FilterType.NO_FILTER,
onClick = { onClick(DrawerDefaultItemsSelection.ARTICLES) }, onClick = { onClick(FilterType.NO_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
@ -143,8 +137,8 @@ fun DrawerDefaultItems(
contentDescription = null contentDescription = null
) )
}, },
selected = selectedItem == DrawerDefaultItemsSelection.NEW, selected = selectedItem == FilterType.NEW,
onClick = { onClick(DrawerDefaultItemsSelection.NEW) }, onClick = { onClick(FilterType.NEW) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
@ -156,8 +150,8 @@ fun DrawerDefaultItems(
contentDescription = null contentDescription = null
) )
}, },
selected = selectedItem == DrawerDefaultItemsSelection.FAVORITES, selected = selectedItem == FilterType.STARS_FILTER,
onClick = { onClick(DrawerDefaultItemsSelection.FAVORITES) }, onClick = { onClick(FilterType.STARS_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
@ -169,8 +163,8 @@ fun DrawerDefaultItems(
contentDescription = null contentDescription = null
) )
}, },
selected = selectedItem == DrawerDefaultItemsSelection.READ_LATER, selected = selectedItem == FilterType.READ_IT_LATER_FILTER,
onClick = { onClick(DrawerDefaultItemsSelection.READ_LATER) }, onClick = { onClick(FilterType.READ_IT_LATER_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding) modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
) )
} }

View File

@ -82,6 +82,7 @@ dependencies {
kapt "androidx.room:room-compiler:$room_version" kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-rxjava2:$room_version" implementation "androidx.room:room-rxjava2:$room_version"
androidTestImplementation "androidx.room:room-testing:$room_version" androidTestImplementation "androidx.room:room-testing:$room_version"
implementation "androidx.room:room-paging:$room_version"
implementation 'com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4' implementation 'com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4'
kapt 'com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4' kapt 'com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4'
@ -98,4 +99,7 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4'
api "androidx.paging:paging-runtime:3.1.1"
api "androidx.paging:paging-compose:1.0.0-alpha18"
} }

View File

@ -18,7 +18,7 @@ import io.reactivex.Completable
interface ItemDao : BaseDao<Item> { interface ItemDao : BaseDao<Item> {
@RawQuery(observedEntities = [Item::class, Folder::class, Feed::class, ItemState::class]) @RawQuery(observedEntities = [Item::class, Folder::class, Feed::class, ItemState::class])
fun selectAll(query: SupportSQLiteQuery): DataSource.Factory<Int?, ItemWithFeed> fun selectAll(query: SupportSQLiteQuery): DataSource.Factory<Int, ItemWithFeed>
@Query("Select * From Item Where id = :itemId") @Query("Select * From Item Where id = :itemId")
fun select(itemId: Int): Item fun select(itemId: Int): Item

View File

@ -1,5 +1,6 @@
package com.readrops.db.dao.newdao package com.readrops.db.dao.newdao
import androidx.paging.PagingSource
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery import androidx.room.RawQuery
@ -9,13 +10,12 @@ import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item import com.readrops.db.entities.Item
import com.readrops.db.entities.ItemState import com.readrops.db.entities.ItemState
import com.readrops.db.pojo.ItemWithFeed import com.readrops.db.pojo.ItemWithFeed
import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class NewItemDao : NewBaseDao<Item> { abstract class NewItemDao : NewBaseDao<Item> {
@RawQuery(observedEntities = [Item::class, Feed::class, Folder::class, ItemState::class]) @RawQuery(observedEntities = [Item::class, Feed::class, Folder::class, ItemState::class])
abstract fun selectAll(query: SupportSQLiteQuery): Flow<List<ItemWithFeed>> abstract fun selectAll(query: SupportSQLiteQuery): PagingSource<Int, ItemWithFeed>
@Query("Select count(*) From Item Where feed_id = :feedId And read = 0") @Query("Select count(*) From Item Where feed_id = :feedId And read = 0")
abstract fun selectUnreadCount(feedId: Int): Int abstract fun selectUnreadCount(feedId: Int): Int

View File

@ -5,5 +5,6 @@ enum class FilterType {
FOLDER_FILER, FOLDER_FILER,
READ_IT_LATER_FILTER, READ_IT_LATER_FILTER,
STARS_FILTER, STARS_FILTER,
NO_FILTER NO_FILTER,
NEW
} }

View File

@ -81,7 +81,7 @@ object ItemsQueryBuilder {
} }
class QueryFilters( data class QueryFilters(
var showReadItems: Boolean = true, var showReadItems: Boolean = true,
var filterFeedId: Int = 0, var filterFeedId: Int = 0,
var filterFolderId: Int = 0, var filterFolderId: Int = 0,