fix: pagination in subscription list (#911)

This commit is contained in:
Diego Beraldin 2024-06-01 10:10:17 +02:00 committed by GitHub
parent 2b7a885904
commit bb507624fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 409 additions and 192 deletions

View File

@ -193,34 +193,49 @@ class DefaultCommunityRepositoryTest {
fun givenSuccess_whenGetSubscribed_thenResultIsAsExpected() =
runTest {
coEvery {
siteService.get(
searchService.search(
authHeader = any(),
auth = any(),
q = any(),
communityId = any(),
communityName = any(),
creatorId = any(),
type = any(),
sort = any(),
listingType = any(),
page = any(),
limit = any(),
)
} returns
mockk {
every { myUser } returns
mockk {
every { follows } returns
listOf(
mockk {
every { community } returns mockk(relaxed = true)
},
)
}
every { communities } returns listOf(
mockk(relaxed = true) {
every { community } returns mockk(relaxed = true)
},
)
}
val token = "fake-token"
val res =
sut.getSubscribed(
auth = token,
page = 1,
)
assertEquals(1, res.size)
coVerify {
siteService.get(
auth = token,
searchService.search(
authHeader = token.toAuthHeader(),
auth = token,
q = "",
communityId = null,
communityName = null,
creatorId = null,
type = SearchType.Communities,
sort = null,
listingType = ListingType.Subscribed.toDto(),
page = 1,
limit = 20,
)
}
}

View File

@ -36,7 +36,12 @@ interface CommunityRepository {
auth: String? = null,
): CommunityModel?
suspend fun getSubscribed(auth: String? = null): List<CommunityModel>
suspend fun getSubscribed(
auth: String? = null,
page: Int,
limit: Int = DEFAULT_PAGE_SIZE,
query: String = "",
): List<CommunityModel>
suspend fun get(
auth: String? = null,

View File

@ -117,15 +117,25 @@ internal class DefaultCommunityRepository(
}.getOrNull()
}
override suspend fun getSubscribed(auth: String?): List<CommunityModel> =
override suspend fun getSubscribed(
auth: String?,
page: Int,
limit: Int,
query: String,
): List<CommunityModel> =
withContext(Dispatchers.IO) {
runCatching {
val response =
services.site.get(
services.search.search(
authHeader = auth.toAuthHeader(),
q = query,
auth = auth,
page = page,
limit = limit,
type = SearchResultType.Communities.toDto(),
listingType = ListingType.Subscribed.toDto(),
)
response.myUser?.follows?.map { it.community.toModel() }.orEmpty()
response.communities.map { it.toModel() }
}.getOrElse { emptyList() }
}

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
@ -15,6 +16,7 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -39,6 +41,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
@ -274,6 +277,23 @@ object ModalDrawerContent : Tab {
},
)
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {
model.reduce(ModalDrawerMviModel.Intent.LoadNextPage)
}
if (uiState.loading && !uiState.refreshing) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
PullRefreshIndicator(
refreshing = uiState.refreshing,

View File

@ -15,9 +15,12 @@ interface ModalDrawerMviModel :
data object Refresh : Intent
data class SetSearch(val value: String) : Intent
data object LoadNextPage : Intent
}
data class UiState(
val loading: Boolean = false,
val canFetchMore: Boolean = true,
val user: UserModel? = null,
val autoLoadImages: Boolean = true,
val preferNicknames: Boolean = true,

View File

@ -43,6 +43,7 @@ class ModalDrawerViewModel(
DefaultMviModel<ModalDrawerMviModel.Intent, ModalDrawerMviModel.UiState, ModalDrawerMviModel.Effect>(
initialState = ModalDrawerMviModel.UiState(),
) {
private var currentPage = 1
private val searchEventChannel = Channel<Unit>()
init {
@ -116,16 +117,17 @@ class ModalDrawerViewModel(
override fun reduce(intent: ModalDrawerMviModel.Intent) {
when (intent) {
ModalDrawerMviModel.Intent.Refresh ->
screenModelScope.launch {
refresh()
}
ModalDrawerMviModel.Intent.Refresh -> screenModelScope.launch {
refresh()
}
is ModalDrawerMviModel.Intent.SetSearch -> {
screenModelScope.launch {
updateState { it.copy(searchText = intent.value) }
searchEventChannel.send(Unit)
}
is ModalDrawerMviModel.Intent.SetSearch -> screenModelScope.launch {
updateState { it.copy(searchText = intent.value) }
searchEventChannel.send(Unit)
}
ModalDrawerMviModel.Intent.LoadNextPage -> screenModelScope.launch {
loadNextPage()
}
}
}
@ -154,33 +156,18 @@ class ModalDrawerViewModel(
if (uiState.value.refreshing) {
return
}
updateState { it.copy(refreshing = true) }
currentPage = 1
updateState {
it.copy(
refreshing = true,
canFetchMore = true,
loading = false,
)
}
val auth = identityRepository.authToken.value
val accountId = accountRepository.getActive()?.id
val favoriteCommunityIds =
favoriteCommunityRepository.getAll(accountId).map { it.communityId }
val searchText = uiState.value.searchText
val communities =
communityRepository.getSubscribed(auth)
.let {
if (searchText.isEmpty()) {
it
} else {
it.filter { e ->
listOf(e.name, e.title).any { s -> s.contains(other = searchText, ignoreCase = true) }
}
}
}.map { community ->
community.copy(favorite = community.id in favoriteCommunityIds)
}
.sortedBy { it.name }
.let {
val favorites = it.filter { e -> e.favorite }
val res = it - favorites.toSet()
favorites + res
}
val multiCommunitites =
val multiCommunities =
accountId?.let {
multiCommunityRepository.getAll(it)
.let { communities ->
@ -192,12 +179,52 @@ class ModalDrawerViewModel(
}
.sortedBy { e -> e.name }
}.orEmpty()
updateState {
it.copy(
multiCommunities = multiCommunities,
)
}
loadNextPage()
}
private suspend fun loadNextPage() {
val currentState = uiState.value
if (!currentState.canFetchMore || currentState.loading) {
updateState { it.copy(refreshing = false) }
return
}
val accountId = accountRepository.getActive()?.id
val auth = identityRepository.authToken.value
val searchText = uiState.value.searchText
val favoriteCommunityIds =
favoriteCommunityRepository.getAll(accountId).map { it.communityId }
val itemsToAdd = communityRepository.getSubscribed(
auth = auth,
page = currentPage,
query = searchText,
).map { community ->
community.copy(favorite = community.id in favoriteCommunityIds)
}.sortedBy { it.name }.let {
val favorites = it.filter { e -> e.favorite }
val res = it - favorites.toSet()
favorites + res
}
if (itemsToAdd.isNotEmpty()) {
currentPage++
}
updateState {
it.copy(
isFiltering = searchText.isNotEmpty(),
refreshing = false,
communities = communities,
multiCommunities = multiCommunitites,
communities = if (currentState.refreshing) {
itemsToAdd
} else {
currentState.communities + itemsToAdd
},
canFetchMore = itemsToAdd.isNotEmpty(),
loading = false,
)
}
}

View File

@ -22,6 +22,8 @@ interface ManageSubscriptionsMviModel :
data class ToggleFavorite(val id: Long) : Intent
data class SetSearch(val value: String) : Intent
data object LoadNextPage : Intent
}
data class UiState(
@ -32,6 +34,8 @@ interface ManageSubscriptionsMviModel :
val autoLoadImages: Boolean = true,
val preferNicknames: Boolean = true,
val searchText: String = "",
val canFetchMore: Boolean = true,
val loading: Boolean = false,
)
sealed interface Effect {

View File

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -29,6 +30,7 @@ import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -60,6 +62,7 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import com.github.diegoberaldin.raccoonforlemmy.core.appearance.theme.Spacing
@ -431,6 +434,23 @@ class ManageSubscriptionsScreen : Screen {
)
}
}
item {
if (!uiState.loading && !uiState.refreshing && uiState.canFetchMore) {
model.reduce(ManageSubscriptionsMviModel.Intent.LoadNextPage)
}
if (uiState.loading && !uiState.refreshing) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
if (!uiState.initial) {

View File

@ -36,6 +36,7 @@ class ManageSubscriptionsViewModel(
DefaultMviModel<ManageSubscriptionsMviModel.Intent, ManageSubscriptionsMviModel.UiState, ManageSubscriptionsMviModel.Effect>(
initialState = ManageSubscriptionsMviModel.UiState(),
) {
private var currentPage = 1
private val searchEventChannel = Channel<Unit>()
init {
@ -61,16 +62,16 @@ class ManageSubscriptionsViewModel(
emitEffect(ManageSubscriptionsMviModel.Effect.BackToTop)
refresh()
}.launchIn(this)
}
if (uiState.value.communities.isEmpty()) {
refresh()
if (uiState.value.communities.isEmpty()) {
refresh(initial = true)
}
}
}
override fun reduce(intent: ManageSubscriptionsMviModel.Intent) {
when (intent) {
ManageSubscriptionsMviModel.Intent.HapticIndication -> hapticFeedback.vibrate()
ManageSubscriptionsMviModel.Intent.Refresh -> refresh()
ManageSubscriptionsMviModel.Intent.Refresh -> screenModelScope.launch { refresh() }
is ManageSubscriptionsMviModel.Intent.Unsubscribe -> {
uiState.value.communities.firstOrNull { it.id == intent.id }?.also { community ->
unsubscribe(community)
@ -92,57 +93,43 @@ class ManageSubscriptionsViewModel(
}
is ManageSubscriptionsMviModel.Intent.SetSearch -> updateSearchText(intent.value)
ManageSubscriptionsMviModel.Intent.LoadNextPage -> screenModelScope.launch {
loadNextPage()
}
}
}
private fun refresh() {
if (uiState.value.refreshing) {
return
private suspend fun refresh(initial: Boolean = false) {
updateState {
it.copy(
refreshing = true,
initial = initial,
canFetchMore = true,
)
}
screenModelScope.launch {
updateState { it.copy(refreshing = true) }
val auth = identityRepository.authToken.value
val accountId = accountRepository.getActive()?.id ?: 0L
val favoriteCommunityIds =
favoriteCommunityRepository.getAll(accountId).map { it.communityId }
val communities =
communityRepository.getSubscribed(auth)
.let {
val searchText = uiState.value.searchText
if (searchText.isNotEmpty()) {
it.filter { c ->
c.title.contains(searchText, ignoreCase = true) ||
c.name.contains(searchText, ignoreCase = true)
}
} else {
it
}
}.map { community ->
community.copy(favorite = community.id in favoriteCommunityIds)
}.sortedBy { it.name }
val multiCommunitites =
multiCommunityRepository.getAll(accountId)
.let {
val searchText = uiState.value.searchText
if (searchText.isNotEmpty()) {
it.filter { c ->
c.name.contains(searchText, ignoreCase = true)
}
} else {
it
currentPage = 1
val accountId = accountRepository.getActive()?.id ?: 0L
val multiCommunitites =
multiCommunityRepository.getAll(accountId)
.let {
val searchText = uiState.value.searchText
if (searchText.isNotEmpty()) {
it.filter { c ->
c.name.contains(searchText, ignoreCase = true)
}
} else {
it
}
.sortedBy { it.name }
}
.sortedBy { it.name }
updateState {
it.copy(
refreshing = false,
initial = false,
communities = communities,
multiCommunities = multiCommunitites,
)
}
updateState {
it.copy(
multiCommunities = multiCommunitites,
)
}
loadNextPage()
}
private fun unsubscribe(community: CommunityModel) {
@ -229,4 +216,44 @@ class ManageSubscriptionsViewModel(
searchEventChannel.send(Unit)
}
}
private suspend fun loadNextPage() {
val currentState = uiState.value
if (!currentState.canFetchMore || currentState.loading) {
updateState { it.copy(refreshing = false) }
return
}
val accountId = accountRepository.getActive()?.id
val auth = identityRepository.authToken.value
val searchText = uiState.value.searchText
val favoriteCommunityIds =
favoriteCommunityRepository.getAll(accountId).map { it.communityId }
val itemsToAdd = communityRepository.getSubscribed(
auth = auth,
page = currentPage,
query = searchText,
).map { community ->
community.copy(favorite = community.id in favoriteCommunityIds)
}.sortedBy { it.name }.let {
val favorites = it.filter { e -> e.favorite }
val res = it - favorites.toSet()
favorites + res
}
if (itemsToAdd.isNotEmpty()) {
currentPage++
}
updateState {
it.copy(
refreshing = false,
communities = if (currentState.refreshing) {
itemsToAdd
} else {
currentState.communities + itemsToAdd
},
canFetchMore = itemsToAdd.isNotEmpty(),
loading = false,
initial = false,
)
}
}
}

View File

@ -17,6 +17,8 @@ interface MultiCommunityEditorMviModel :
data class ToggleCommunity(val id: Long) : Intent
data object LoadNextPage : Intent
data object Submit : Intent
}
@ -27,8 +29,12 @@ interface MultiCommunityEditorMviModel :
val nameError: ValidationError? = null,
val icon: String? = null,
val availableIcons: List<String> = emptyList(),
val communities: List<Pair<CommunityModel, Boolean>> = emptyList(),
val communities: List<CommunityModel> = emptyList(),
val selectedCommunityIds: List<Long> = emptyList(),
val searchText: String = "",
val refreshing: Boolean = false,
val loading: Boolean = false,
val canFetchMore: Boolean = true,
)
sealed interface Effect {

View File

@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -348,9 +349,8 @@ class MultiCommunityEditorScreen(
.weight(1f),
verticalArrangement = Arrangement.spacedBy(Spacing.xxs),
) {
items(uiState.communities) { communityItem ->
val community = communityItem.first
val selected = communityItem.second
items(uiState.communities) { community ->
val selected = uiState.selectedCommunityIds.contains(community.id)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
@ -375,6 +375,23 @@ class MultiCommunityEditorScreen(
)
}
}
item {
if (!uiState.loading && uiState.canFetchMore) {
model.reduce(MultiCommunityEditorMviModel.Intent.LoadNextPage)
}
if (uiState.loading) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}

View File

@ -10,7 +10,6 @@ import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.Mult
import com.github.diegoberaldin.raccoonforlemmy.core.persistence.repository.SettingsRepository
import com.github.diegoberaldin.raccoonforlemmy.core.utils.ValidationError
import com.github.diegoberaldin.raccoonforlemmy.domain.identity.repository.IdentityRepository
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.data.CommunityModel
import com.github.diegoberaldin.raccoonforlemmy.domain.lemmy.repository.CommunityRepository
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
@ -33,7 +32,7 @@ class MultiCommunityEditorViewModel(
DefaultMviModel<MultiCommunityEditorMviModel.Intent, MultiCommunityEditorMviModel.UiState, MultiCommunityEditorMviModel.Effect>(
initialState = MultiCommunityEditorMviModel.UiState(),
) {
private var communities: List<Pair<CommunityModel, Boolean>> = emptyList()
private var currentPage = 1
private val searchEventChannel = Channel<Unit>()
init {
@ -48,14 +47,19 @@ class MultiCommunityEditorViewModel(
}.launchIn(this)
searchEventChannel.receiveAsFlow().debounce(1000).onEach {
updateState {
val filtered = filterCommunities()
it.copy(communities = filtered)
}
refresh()
}.launchIn(this)
}
if (communities.isEmpty()) {
populate()
if (uiState.value.communities.isEmpty()) {
val editedCommunity =
communityId?.let {
multiCommunityRepository.getById(it)
}
updateState {
it.copy(selectedCommunityIds = editedCommunity?.communityIds.orEmpty())
}
refresh(initial = true)
}
}
}
@ -68,34 +72,62 @@ class MultiCommunityEditorViewModel(
}
is MultiCommunityEditorMviModel.Intent.ToggleCommunity -> toggleCommunity(intent.id)
is MultiCommunityEditorMviModel.Intent.SetSearch -> setSearch(intent.value)
MultiCommunityEditorMviModel.Intent.LoadNextPage -> screenModelScope.launch {
loadNextPage()
}
MultiCommunityEditorMviModel.Intent.Submit -> submit()
}
}
private fun populate() {
screenModelScope.launch {
val editedCommunity =
communityId?.let {
multiCommunityRepository.getById(it)
}
val auth = identityRepository.authToken.value
communities =
communityRepository.getSubscribed(auth).sortedBy { it.name }.map { c ->
c to (editedCommunity?.communityIds?.contains(c.id) == true)
}
updateState {
val newCommunities = communities
val availableIcons = newCommunities.filter { i -> i.second }.mapNotNull { i -> i.first.icon }
it.copy(
communities = newCommunities,
name = editedCommunity?.name.orEmpty(),
icon = editedCommunity?.icon,
availableIcons = availableIcons,
)
}
private suspend fun refresh(initial: Boolean = false) {
val editedCommunity = communityId?.let {
multiCommunityRepository.getById(it)
}
currentPage = 1
updateState {
it.copy(
name = editedCommunity?.name.orEmpty(),
icon = editedCommunity?.icon,
refreshing = !initial,
loading = false,
canFetchMore = true,
)
}
loadNextPage()
}
private suspend fun loadNextPage() {
val currentState = uiState.value
if (!currentState.canFetchMore || currentState.loading) {
return
}
val auth = identityRepository.authToken.value
val searchText = uiState.value.searchText
val itemsToAdd = communityRepository.getSubscribed(
auth = auth,
page = currentPage,
query = searchText,
)
if (itemsToAdd.isNotEmpty()) {
currentPage++
}
updateState {
it.copy(
communities = if (currentState.refreshing) {
itemsToAdd
} else {
currentState.communities + itemsToAdd
},
canFetchMore = itemsToAdd.isNotEmpty(),
loading = false,
refreshing = false,
)
}
}
private fun setSearch(value: String) {
screenModelScope.launch {
updateState { it.copy(searchText = value) }
@ -103,17 +135,6 @@ class MultiCommunityEditorViewModel(
}
}
private fun filterCommunities(): List<Pair<CommunityModel, Boolean>> {
val searchText = uiState.value.searchText
val res =
if (searchText.isNotEmpty()) {
communities.filter { it.first.name.contains(other = searchText, ignoreCase = true) }
} else {
communities
}
return res
}
private fun selectImage(index: Int?) {
screenModelScope.launch {
val image =
@ -127,28 +148,26 @@ class MultiCommunityEditorViewModel(
}
private fun toggleCommunity(communityId: Long) {
val newCommunities =
communities.map { item ->
if (item.first.id == communityId) {
item.first to !item.second
} else {
item
}
}
val availableIcons =
newCommunities.filter { i ->
i.second
}.mapNotNull { i ->
i.first.icon
}
communities = newCommunities
val filtered = filterCommunities()
screenModelScope.launch {
updateState { state ->
state.copy(
communities = filtered,
availableIcons = availableIcons,
)
val currentCommunityIds = uiState.value.selectedCommunityIds
val shouldBeRemoved = currentCommunityIds.contains(communityId)
val iconUrl = uiState.value.communities.firstOrNull { c -> c.id == communityId }?.icon
if (shouldBeRemoved) {
updateState {
it.copy(
selectedCommunityIds = currentCommunityIds.filter { id -> id != communityId },
availableIcons = it.availableIcons.filter { url -> url != iconUrl }
)
}
} else {
updateState {
it.copy(
selectedCommunityIds = currentCommunityIds + communityId,
availableIcons = if (iconUrl != null) {
it.availableIcons + iconUrl
} else it.availableIcons
)
}
}
}
}
@ -172,7 +191,7 @@ class MultiCommunityEditorViewModel(
screenModelScope.launch {
val icon = currentState.icon
val communityIds = currentState.communities.filter { it.second }.map { it.first.id }
val communityIds = currentState.selectedCommunityIds
val editedCommunity =
communityId?.let {
multiCommunityRepository.getById(it)

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
@ -18,6 +19,7 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -150,6 +152,23 @@ class SelectCommunityDialog : Screen {
community = community,
)
}
item {
if (!uiState.initial && !uiState.loading && uiState.canFetchMore) {
model.reduce(SelectCommunityMviModel.Intent.LoadNextPage)
}
if (!uiState.initial && uiState.loading) {
Box(
modifier = Modifier.fillMaxWidth().padding(Spacing.xs),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(25.dp),
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}

View File

@ -9,6 +9,8 @@ interface SelectCommunityMviModel :
ScreenModel {
sealed interface Intent {
data class SetSearch(val value: String) : Intent
data object LoadNextPage : Intent
}
data class UiState(
@ -17,6 +19,9 @@ interface SelectCommunityMviModel :
val searchText: String = "",
val autoLoadImages: Boolean = true,
val preferNicknames: Boolean = true,
val loading: Boolean = false,
val refreshing: Boolean = false,
val canFetchMore: Boolean = true,
)
sealed interface Effect

View File

@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@OptIn(FlowPreview::class)
class SelectCommunityViewModel(
private val identityRepository: IdentityRepository,
private val communityRepository: CommunityRepository,
@ -25,7 +24,7 @@ class SelectCommunityViewModel(
DefaultMviModel<SelectCommunityMviModel.Intent, SelectCommunityMviModel.UiState, SelectCommunityMviModel.Effect>(
initialState = SelectCommunityMviModel.UiState(),
) {
private var communities: List<CommunityModel> = emptyList()
private var currentPage = 1
private val searchEventChannel = Channel<Unit>()
init {
@ -39,21 +38,23 @@ class SelectCommunityViewModel(
}
}.launchIn(this)
@OptIn(FlowPreview::class)
searchEventChannel.receiveAsFlow().debounce(1000).onEach {
updateState {
val filtered = filterCommunities()
it.copy(communities = filtered)
}
refresh()
}.launchIn(this)
}
if (communities.isEmpty()) {
populate()
if (uiState.value.initial) {
refresh(initial = true)
}
}
}
override fun reduce(intent: SelectCommunityMviModel.Intent) {
when (intent) {
is SelectCommunityMviModel.Intent.SetSearch -> setSearch(intent.value)
SelectCommunityMviModel.Intent.LoadNextPage -> screenModelScope.launch {
loadNextPage()
}
}
}
@ -64,27 +65,46 @@ class SelectCommunityViewModel(
}
}
private fun populate() {
screenModelScope.launch(Dispatchers.IO) {
val auth = identityRepository.authToken.value
communities = communityRepository.getSubscribed(auth).sortedBy { it.name }
updateState {
it.copy(
initial = false,
communities = communities,
)
}
private suspend fun refresh(initial: Boolean = false) {
currentPage = 1
updateState {
it.copy(
initial = initial,
canFetchMore = true,
loading = false,
refreshing = true,
)
}
loadNextPage()
}
private fun filterCommunities(): List<CommunityModel> {
private suspend fun loadNextPage() {
val currentState = uiState.value
if (!currentState.canFetchMore || currentState.loading) {
return
}
val auth = identityRepository.authToken.value
val searchText = uiState.value.searchText
val res =
if (searchText.isNotEmpty()) {
communities.filter { it.name.contains(searchText) }
} else {
communities
}
return res
val itemsToAdd = communityRepository.getSubscribed(
auth = auth,
page = currentPage,
query = searchText,
)
if (itemsToAdd.isNotEmpty()) {
currentPage++
}
updateState {
it.copy(
communities = if (currentState.refreshing) {
itemsToAdd
} else {
currentState.communities + itemsToAdd
},
canFetchMore = itemsToAdd.isNotEmpty(),
loading = false,
initial = false,
refreshing = false,
)
}
}
}