refactor: consolidate convos state and intent handling

This commit is contained in:
2026-05-30 15:39:43 +03:00
parent 167f980f29
commit f11b8dc6f4
13 changed files with 392 additions and 402 deletions
@@ -38,7 +38,7 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.navigation.convosGraph import dev.meloda.fast.convos.navigation.convosGraph
import dev.meloda.fast.friends.navigation.Friends import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.friends.navigation.friendsScreen import dev.meloda.fast.friends.navigation.friendsScreen
@@ -198,15 +198,17 @@ fun MainScreen(
}, },
) )
convosGraph( convosGraph(
handleNavigationIntent = { intent ->
when (intent) {
ConvoNavigationIntent.Back -> {}
ConvoNavigationIntent.Archive -> {}
ConvoNavigationIntent.CreateChat -> onNavigateToCreateChat()
is ConvoNavigationIntent.MessagesHistory -> {
onNavigateToMessagesHistory(intent.convoId)
}
}
},
activity = activity, activity = activity,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onScrolledToTop = {
tabReselected = tabReselected.toMutableMap().also {
it[ConvoGraph] = false
}
}
) )
profileScreen( profileScreen(
activity = activity, activity = activity,
@@ -3,6 +3,7 @@ package dev.meloda.fast.convos
import android.content.Context import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import android.os.Bundle import android.os.Bundle
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader import coil.ImageLoader
@@ -15,10 +16,12 @@ import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkUtils import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
@@ -28,25 +31,23 @@ import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import dev.meloda.fast.ui.util.buildImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@Immutable
class ConvosViewModel( class ConvosViewModel(
updatesParser: LongPollUpdatesParser, updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter, val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase, private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resources: Resources, private val resources: Resources,
@@ -59,40 +60,18 @@ class ConvosViewModel(
private val screenState = MutableStateFlow(ConvosScreenState.EMPTY) private val screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenStateFlow get() = screenState.asStateFlow() val screenStateFlow get() = screenState.asStateFlow()
private val navigation = MutableStateFlow<ConvoNavigation?>(null) private val navigationIntent = MutableStateFlow<ConvoNavigationIntent?>(null)
val navigationFlow get() = navigation.asStateFlow() val navigationIntentFlow get() = navigationIntent.asStateFlow()
private val dialog = MutableStateFlow<ConvoDialog?>(null) private val convos: MutableList<VkConvo> = mutableListOf()
val dialogFlow get() = dialog.asStateFlow()
private val convos = MutableStateFlow<List<VkConvo>>(emptyList()) private val pinnedConvosCount get() = convos.count(VkConvo::isPinned)
val convosFlow get() = convos.asStateFlow()
private val uiConvos = MutableStateFlow<List<UiConvo>>(emptyList()) private var currentOffset = 0
val uiConvosFlow get() = uiConvos.asStateFlow()
private val pinnedConvosCount = convosFlow.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val baseError = MutableStateFlow<BaseError?>(null)
val baseErrorFlow get() = baseError.asStateFlow()
private val currentOffset = MutableStateFlow(0)
val currentOffsetFlow get() = currentOffset.asStateFlow()
private val canPaginate = MutableStateFlow(false)
val canPaginateFlow get() = canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>() private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init { init {
screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos() loadConvos()
updatesParser.onNewMessage(::handleNewMessage) updatesParser.onNewMessage(::handleNewMessage)
@@ -110,100 +89,143 @@ class ConvosViewModel(
} }
} }
fun onNavigationConsumed() { fun handleIntent(intent: ConvoIntent) {
navigation.setValue { null } when (intent) {
ConvoIntent.ArchiveClick -> {
navigationIntent.setValue { ConvoNavigationIntent.Archive }
} }
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) { ConvoIntent.Back -> {
onDialogDismissed(dialog) navigationIntent.setValue { ConvoNavigationIntent.Back }
}
ConvoIntent.ConsumeScrollToTop -> Unit
ConvoIntent.CreateChatClick -> {
navigationIntent.setValue { ConvoNavigationIntent.CreateChat }
}
ConvoIntent.ErrorActionButtonClick -> {
onRefresh()
}
is ConvoIntent.ItemClick -> {
onConvoItemClick(intent.convoId)
}
is ConvoIntent.ItemLongClick -> {
onConvoItemLongClick(intent.convoId)
}
is ConvoIntent.OptionItemClick -> {
onOptionClicked(intent.option)
}
ConvoIntent.PaginationConditionsMet -> {
onPaginationConditionsMet()
}
ConvoIntent.Refresh -> {
onRefresh()
}
is ConvoIntent.SetScrollIndex -> {
setScrollIndex(intent.index)
}
is ConvoIntent.SetScrollOffset -> {
setScrollOffset(intent.offset)
}
is ConvoIntent.Dialog -> {
when (intent) {
is ConvoIntent.Dialog.Cancel -> Unit
is ConvoIntent.Dialog.Confirm -> onDialogConfirmed(intent.bundle)
ConvoIntent.Dialog.Dismiss -> onDialogDismissed()
}
}
}
}
fun onNavigationConsumed() {
navigationIntent.setValue { null }
}
private fun onDialogConfirmed(bundle: Bundle?) {
val dialog = screenState.value.dialog ?: return
onDialogDismissed()
val convo = with(screenState.value) {
convos.find { it.id == expandedConvoId }
} ?: return
when (dialog) { when (dialog) {
is ConvoDialog.ConvoDelete -> { is ConvoDialog.Delete -> {
deleteConvo(dialog.convoId) deleteConvo(convo.id)
} }
is ConvoDialog.ConvoPin -> { is ConvoDialog.Pin -> {
pinConvo(dialog.convoId, true) pinConvo(convo.id, true)
} }
is ConvoDialog.ConvoUnpin -> { is ConvoDialog.Unpin -> {
pinConvo(dialog.convoId, false) pinConvo(convo.id, false)
} }
is ConvoDialog.ConvoArchive -> { is ConvoDialog.Archive -> {
archiveConvo(dialog.convoId, true) archiveConvo(convo.id, true)
} }
is ConvoDialog.ConvoUnarchive -> { is ConvoDialog.Unarchive -> {
archiveConvo(dialog.convoId, false) archiveConvo(convo.id, false)
} }
} }
expandedConvoId.setValue { 0 } collapseConvos(false)
syncUiConvos() syncUiConvos()
} }
fun onDialogDismissed(dialog: ConvoDialog) { private fun onDialogDismissed() {
this.dialog.setValue { null } screenState.updateValue { copy(dialog = null) }
} }
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) { private fun onPaginationConditionsMet() {
when (dialog) { currentOffset = convos.size
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseErrorFlow.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
currentOffset.update { convosFlow.value.size }
loadConvos() loadConvos()
} }
fun onRefresh() { private fun onErrorConsumed() {
screenState.updateValue { copy(error = null) }
}
private fun onRefresh() {
onErrorConsumed() onErrorConsumed()
loadConvos(offset = 0) loadConvos(offset = 0)
} }
fun onConvoItemClick(convo: UiConvo) { private fun onConvoItemClick(convoId: Long) {
collapseConvos() collapseConvos()
navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) } navigationIntent.setValue { ConvoNavigationIntent.MessagesHistory(convoId) }
} }
fun onConvoItemLongClick(convo: UiConvo) { private fun onConvoItemLongClick(convoId: Long) {
expandedConvoId.setValue { val isExpanded = screenState.value.convos.find { it.id == convoId }?.isExpanded == true
if (convo.isExpanded) 0
else convo.id screenState.updateValue { copy(expandedConvoId = if (isExpanded) 0L else convoId) }
}
syncUiConvos() syncUiConvos()
} }
fun onOptionClicked( private fun onOptionClicked(option: ConvoOption) {
convo: UiConvo, val convo =
option: ConvoOption screenState.value.convos.find { it.id == screenState.value.expandedConvoId } ?: return
) {
when (option) { when (option) {
ConvoOption.Delete -> { ConvoOption.Delete -> setDialog(ConvoDialog.Delete)
dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> { ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId -> val lastMessageId =
screenState.value.convos.find { it.id == screenState.value.expandedConvoId }?.lastMessageId
if (lastMessageId != null) {
readConvo( readConvo(
peerId = convo.id, peerId = convo.id,
startMessageId = lastMessageId startMessageId = lastMessageId
@@ -212,48 +234,39 @@ class ConvosViewModel(
} }
} }
ConvoOption.Pin -> { ConvoOption.Pin -> setDialog(ConvoDialog.Pin)
dialog.setValue { ConvoDialog.ConvoPin(convo.id) } ConvoOption.Unpin -> setDialog(ConvoDialog.Unpin)
} ConvoOption.Archive -> setDialog(ConvoDialog.Archive)
ConvoOption.Unarchive -> setDialog(ConvoDialog.Unarchive)
ConvoOption.Unpin -> {
dialog.setValue { ConvoDialog.ConvoUnpin(convo.id) }
}
ConvoOption.Archive -> {
dialog.setValue { ConvoDialog.ConvoArchive(convo.id) }
}
ConvoOption.Unarchive -> {
dialog.setValue { ConvoDialog.ConvoUnarchive(convo.id) }
}
} }
} }
fun onErrorConsumed() { private fun setScrollIndex(index: Int) {
baseError.setValue { null }
}
fun setScrollIndex(index: Int) {
screenState.setValue { old -> old.copy(scrollIndex = index) } screenState.setValue { old -> old.copy(scrollIndex = index) }
} }
fun setScrollOffset(offset: Int) { private fun setScrollOffset(offset: Int) {
screenState.setValue { old -> old.copy(scrollOffset = offset) } screenState.setValue { old -> old.copy(scrollOffset = offset) }
} }
fun onCreateChatButtonClicked() { private fun setDialog(dialog: ConvoDialog?) {
navigation.setValue { ConvoNavigation.CreateChat } screenState.updateValue { copy(dialog = dialog) }
} }
private fun collapseConvos() { private fun replaceConvos(newConvos: List<VkConvo>) {
expandedConvoId.setValue { 0 } convos.clear()
convos.addAll(newConvos)
}
private fun collapseConvos(sync: Boolean = true) {
screenState.updateValue { copy(expandedConvoId = null) }
if (sync) {
syncUiConvos() syncUiConvos()
} }
}
private fun loadConvos( private fun loadConvos(offset: Int = currentOffset) {
offset: Int = currentOffsetFlow.value
) {
convoUseCase.getConvos( convoUseCase.getConvos(
count = LOAD_COUNT, count = LOAD_COUNT,
offset = offset, offset = offset,
@@ -261,21 +274,18 @@ class ConvosViewModel(
).listenValue(viewModelScope) { state -> ).listenValue(viewModelScope) { state ->
state.processState( state.processState(
error = { error -> error = { error ->
val newBaseError = VkUtils.parseError(error) screenState.updateValue { copy(error = VkUtils.parseError(error)) }
baseError.update { newBaseError }
}, },
success = { response -> success = { response ->
val convos = response val newConvos = if (offset == 0) {
val fullConvos = if (offset == 0) { response
convos
} else { } else {
this.convosFlow.value.plus(convos) convos.plus(response)
} }
val itemsCountSufficient = response.size == LOAD_COUNT val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient && val paginationExhausted = !itemsCountSufficient && convos.isNotEmpty()
this.convosFlow.value.isNotEmpty()
screenState.updateValue { screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted) copy(isPaginationExhausted = paginationExhausted)
@@ -294,9 +304,10 @@ class ConvosViewModel(
convoUseCase.storeConvos(response) convoUseCase.storeConvos(response)
this.convos.emit(fullConvos) replaceConvos(newConvos)
screenState.updateValue { copy(canPaginate = itemsCountSufficient) }
syncUiConvos() syncUiConvos()
canPaginate.setValue { itemsCountSufficient }
} }
) )
@@ -314,13 +325,13 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId } newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState ?: return@processState
newConvos.removeAt(convoIndex) newConvos.removeAt(convoIndex)
convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
) )
@@ -338,7 +349,7 @@ class ConvosViewModel(
LongPollParsedEvent.ChatMajorChanged( LongPollParsedEvent.ChatMajorChanged(
peerId = peerId, peerId = peerId,
majorId = if (pin) { majorId = if (pin) {
pinnedConvosCount.value.plus(1) * 16 pinnedConvosCount.plus(1) * 16
} else { } else {
0 0
} }
@@ -357,7 +368,7 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
convosFlow.value.find { it.id == peerId }?.let { convo -> convos.find { it.id == peerId }?.let { convo ->
handleChatArchived( handleChatArchived(
LongPollParsedEvent.ChatArchived( LongPollParsedEvent.ChatArchived(
convo = convo, convo = convo,
@@ -374,7 +385,7 @@ class ConvosViewModel(
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) { private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message val message = event.message
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId } newConvos.indexOfFirstOrNull { it.id == message.peerId }
@@ -392,8 +403,8 @@ class ConvosViewModel(
val convo = (response.firstOrNull() ?: return@listenValue) val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message) .copy(lastMessage = message)
newConvos.add(pinnedConvosCount.value, convo) newConvos.add(pinnedConvosCount, convo)
convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
) )
@@ -429,19 +440,17 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvo newConvos[convoIndex] = newConvo
} else { } else {
newConvos.removeAt(convoIndex) newConvos.removeAt(convoIndex)
newConvos.add(pinnedConvosCount, newConvo)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
} }
convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) { private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message val message = event.message
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId } val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке if (convoIndex == null) { // диалога нет в списке
@@ -453,13 +462,14 @@ class ConvosViewModel(
lastMessageId = message.id, lastMessageId = message.id,
lastCmId = message.cmId lastCmId = message.cmId
) )
convos.update { newConvos }
replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
} }
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) { private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -473,13 +483,13 @@ class ConvosViewModel(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
} }
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) { private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -493,7 +503,7 @@ class ConvosViewModel(
unreadCount = event.unreadCount unreadCount = event.unreadCount
) )
convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
} }
@@ -503,7 +513,7 @@ class ConvosViewModel(
val peerId = event.peerId val peerId = event.peerId
val userIds = event.userIds val userIds = event.userIds
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } newConvos.findWithIndex { it.id == peerId }
@@ -514,7 +524,7 @@ class ConvosViewModel(
interactionIds = userIds interactionIds = userIds
) )
convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob -> interactionsTimers[peerId]?.let { interactionJob ->
@@ -546,7 +556,7 @@ class ConvosViewModel(
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) { private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return interactionsTimers[peerId] ?: return
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoAndIndex = val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return newConvos.findWithIndex { it.id == peerId } ?: return
@@ -556,7 +566,7 @@ class ConvosViewModel(
interactionIds = emptyList() interactionIds = emptyList()
) )
convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
interactionJob.timerJob.cancel() interactionJob.timerJob.cancel()
@@ -564,7 +574,7 @@ class ConvosViewModel(
} }
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) { private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -574,13 +584,13 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId) newConvos[convoIndex].copy(majorId = event.majorId)
convos.setValue { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) { private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId } newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -590,13 +600,13 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId) newConvos[convoIndex].copy(minorId = event.minorId)
convos.setValue { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) { private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId } val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
@@ -605,7 +615,7 @@ class ConvosViewModel(
} else { } else {
newConvos.removeAt(convoIndex) newConvos.removeAt(convoIndex)
convos.setValue { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
@@ -613,7 +623,7 @@ class ConvosViewModel(
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) { private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo val convo = event.convo
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
when (filter) { when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit ConvosFilter.BUSINESS_NOTIFY -> Unit
@@ -628,7 +638,7 @@ class ConvosViewModel(
newConvos.removeAt(index) newConvos.removeAt(index)
} }
convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
@@ -639,10 +649,10 @@ class ConvosViewModel(
newConvos.removeAt(index) newConvos.removeAt(index)
} else { } else {
newConvos.add(pinnedConvosCount.value, convo) newConvos.add(pinnedConvosCount, convo)
} }
convos.update { newConvos.sorted() } replaceConvos(newConvos.sorted())
syncUiConvos() syncUiConvos()
} }
} }
@@ -656,7 +666,7 @@ class ConvosViewModel(
state.processState( state.processState(
error = {}, error = {},
success = { success = {
val newConvos = convosFlow.value.toMutableList() val newConvos = convos.toMutableList()
val convoIndex = val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId } newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue ?: return@listenValue
@@ -664,7 +674,7 @@ class ConvosViewModel(
newConvos[convoIndex] = newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId) newConvos[convoIndex].copy(inRead = startMessageId)
convos.update { newConvos } replaceConvos(newConvos)
syncUiConvos() syncUiConvos()
} }
) )
@@ -696,47 +706,44 @@ class ConvosViewModel(
} }
private fun syncUiConvos(): List<UiConvo> { private fun syncUiConvos(): List<UiConvo> {
val convos = convosFlow.value
val newUiConvos = convos.map { convo -> val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>() val options: ImmutableList<ConvoOption> = buildImmutableList {
convo.lastMessage?.run { if (!convo.isRead() && convo.lastMessage != null && convo.lastMessage?.isOut == false) {
if (!convo.isRead() && !this.isOut) { add(ConvoOption.MarkAsRead)
options += ConvoOption.MarkAsRead
} }
}
val convosSize = this.convosFlow.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) { if (convo.isPinned()) {
options += ConvoOption.Unpin add(ConvoOption.Unpin)
} else if (canPinOneMoreDialog) { }
options += ConvoOption.Pin
if (convos.size > 4 && pinnedConvosCount < 5 && !convo.isPinned()) {
add(ConvoOption.Pin)
} }
when (filter) { when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> add(ConvoOption.Unarchive)
ConvosFilter.UNREAD, ConvosFilter.ALL,
ConvosFilter.ALL -> ConvoOption.Archive ConvosFilter.UNREAD -> {
if (convo.id != UserConfig.userId) {
add(ConvoOption.Archive)
}
}
}
ConvosFilter.BUSINESS_NOTIFY -> null add(ConvoOption.Delete)
}?.let(options::add) }
options += ConvoOption.Delete
convo.asPresentation( convo.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames, useContactName = userSettings.useContactNames.value,
isExpanded = expandedConvoId.value == convo.id, isExpanded = screenState.value.expandedConvoId == convo.id,
options = options.toImmutableList() options = options
) )
} }
uiConvos.setValue { newUiConvos }
screenState.updateValue { copy(convos = newUiConvos.toImmutableList()) }
return newUiConvos return newUiConvos
} }
@@ -4,9 +4,9 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed class ConvoDialog { sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog() data object Pin : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog() data object Unpin : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog() data object Delete : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog() data object Archive : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog() data object Unarchive : ConvoDialog()
} }
@@ -0,0 +1,29 @@
package dev.meloda.fast.convos.model
import android.os.Bundle
import dev.meloda.fast.ui.model.vk.ConvoOption
sealed class ConvoIntent {
data class ItemClick(val convoId: Long) : ConvoIntent()
data class ItemLongClick(val convoId: Long) : ConvoIntent()
data class OptionItemClick(val option: ConvoOption) : ConvoIntent()
data object PaginationConditionsMet : ConvoIntent()
data object Back : ConvoIntent()
data object Refresh : ConvoIntent()
data object CreateChatClick : ConvoIntent()
data object ArchiveClick : ConvoIntent()
data class SetScrollIndex(val index: Int) : ConvoIntent()
data class SetScrollOffset(val offset: Int) : ConvoIntent()
data object ErrorActionButtonClick : ConvoIntent()
data object ConsumeScrollToTop : ConvoIntent()
sealed class Dialog : ConvoIntent() {
data object Dismiss : Dialog()
data class Confirm(val bundle: Bundle? = null) : Dialog()
data class Cancel(val bundle: Bundle? = null) : Dialog()
}
}
@@ -1,11 +0,0 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoNavigation {
data class MessagesHistory(val peerId: Long) : ConvoNavigation()
data object CreateChat : ConvoNavigation()
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.convos.model
sealed class ConvoNavigationIntent {
data object Back : ConvoNavigationIntent()
data class MessagesHistory(val convoId: Long) : ConvoNavigationIntent()
data object CreateChat : ConvoNavigationIntent()
data object Archive : ConvoNavigationIntent()
}
@@ -1,6 +1,10 @@
package dev.meloda.fast.convos.model package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
@Immutable @Immutable
data class ConvosScreenState( data class ConvosScreenState(
@@ -10,7 +14,13 @@ data class ConvosScreenState(
val profileImageUrl: String?, val profileImageUrl: String?,
val scrollIndex: Int, val scrollIndex: Int,
val scrollOffset: Int, val scrollOffset: Int,
val isArchive: Boolean val canPaginate: Boolean,
val expandedConvoId: Long?,
val convos: ImmutableList<UiConvo>,
val dialog: ConvoDialog?,
// TODO: 30.05.2026, Danil Nikolaev: remove
val error: BaseError?
) { ) {
companion object { companion object {
@@ -21,7 +31,11 @@ data class ConvosScreenState(
profileImageUrl = null, profileImageUrl = null,
scrollIndex = 0, scrollIndex = 0,
scrollOffset = 0, scrollOffset = 0,
isArchive = false canPaginate = false,
expandedConvoId = null,
convos = emptyImmutableList(),
dialog = null,
error = null
) )
} }
} }
@@ -1,12 +1,16 @@
package dev.meloda.fast.convos.navigation package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigationIntent
import dev.meloda.fast.convos.presentation.ConvosRoute import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController import dev.meloda.fast.ui.theme.LocalNavController
@@ -24,44 +28,56 @@ object Convos
object Archive object Archive
fun NavGraphBuilder.convosGraph( fun NavGraphBuilder.convosGraph(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
activity: AppCompatActivity, activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) { ) {
navigation<ConvoGraph>( navigation<ConvoGraph>(
startDestination = Convos startDestination = Convos
) { ) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> { composable<Convos> {
val navController = LocalNavController.getOrThrow() ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
ConvosRoute( viewModel = with(activity) {
viewModel = convosViewModel, getViewModel(named(ConvosFilter.ALL))
onError = onError, }
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
) )
} }
composable<Archive> { composable<Archive> {
val navController = LocalNavController.getOrThrow() ConvosRootRoute(
handleNavigationIntent = handleNavigationIntent,
ConvosRoute(
viewModel = with(activity) { viewModel = with(activity) {
getViewModel<ConvosViewModel>( getViewModel<ConvosViewModel>(named(ConvosFilter.ARCHIVE))
qualifier = named(ConvosFilter.ARCHIVE) }
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
) )
} }
} }
} }
@Composable
private fun ConvosRootRoute(
handleNavigationIntent: (ConvoNavigationIntent) -> Unit,
viewModel: ConvosViewModel
) {
val navController = LocalNavController.getOrThrow()
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationIntent by viewModel.navigationIntentFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationIntent) {
navigationIntent?.let {
when (navigationIntent) {
ConvoNavigationIntent.Back -> navController.navigateUp()
ConvoNavigationIntent.Archive -> navController.navigate(Archive)
else -> handleNavigationIntent(it)
}
viewModel.onNavigationConsumed()
}
}
ConvosRoute(
handleIntent = viewModel::handleIntent,
screenState = screenState,
isArchive = viewModel.filter == ConvosFilter.ARCHIVE,
)
}
@@ -1,82 +1,78 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
@Composable @Composable
fun HandleDialogs( fun HandleDialogs(
handleIntent: (ConvoIntent) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) { ) {
when (dialog) { when (screenState.dialog) {
null -> Unit null -> Unit
is ConvoDialog.ConvoArchive -> { is ConvoDialog.Archive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_archive_convo), title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_archive), confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_archive_fill_round_24)
) )
} }
is ConvoDialog.ConvoUnarchive -> { is ConvoDialog.Unarchive -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
title = stringResource(id = R.string.confirm_unarchive_convo), title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unarchive), confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24) icon = ImageVector.vectorResource(R.drawable.ic_unarchive_fill_round_24)
) )
} }
is ConvoDialog.ConvoDelete -> { is ConvoDialog.Delete -> {
val errorColor = MaterialTheme.colorScheme.error val errorColor = MaterialTheme.colorScheme.error
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_delete_fill_round_24),
iconTint = errorColor, iconTint = errorColor,
title = stringResource(id = R.string.confirm_delete_convo), title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_delete), confirmText = stringResource(id = R.string.action_delete),
confirmContainerColor = errorColor, confirmContainerColor = errorColor,
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.ConvoPin -> { is ConvoDialog.Pin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_fill_round_24),
title = stringResource(id = R.string.confirm_pin_convo), title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_pin), confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
} }
is ConvoDialog.ConvoUnpin -> { is ConvoDialog.Unpin -> {
MaterialDialog( MaterialDialog(
onDismissRequest = { onDismissed(dialog) }, onDismissRequest = { handleIntent(ConvoIntent.Dialog.Dismiss) },
icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24), icon = ImageVector.vectorResource(R.drawable.ic_keep_off_fill_round_24),
title = stringResource(id = R.string.confirm_unpin_convo), title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) }, confirmAction = { handleIntent(ConvoIntent.Dialog.Confirm()) },
confirmText = stringResource(id = R.string.action_unpin), confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel), cancelText = stringResource(id = R.string.cancel),
) )
@@ -62,9 +62,9 @@ val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ConvoItem( fun ConvoItem(
onItemClick: (UiConvo) -> Unit, onItemClick: (convoId: Long) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit, onItemLongClick: (convoId: Long) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit, onOptionClicked: (ConvoOption) -> Unit,
maxLines: Int, maxLines: Int,
isUserAccount: Boolean, isUserAccount: Boolean,
convo: UiConvo, convo: UiConvo,
@@ -81,9 +81,9 @@ fun ConvoItem(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onItemClick(convo) }, onClick = { onItemClick(convo.id) },
onLongClick = { onLongClick = {
onItemLongClick(convo) onItemLongClick(convo.id)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
} }
) )
@@ -281,7 +281,7 @@ fun ConvoItem(
val builder = val builder =
AnnotatedString.Builder(convo.message.text) AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.map { spanStyleRange -> convo.message.spanStyles.forEach { spanStyleRange ->
val updatedSpanStyle = val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) { if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary) spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
@@ -378,7 +378,7 @@ fun ConvoItem(
} }
ElevatedAssistChip( ElevatedAssistChip(
onClick = { onOptionClicked(convo, option) }, onClick = { onOptionClicked(option) },
leadingIcon = { leadingIcon = {
option.icon.getResourcePainter()?.let { painter -> option.icon.getResourcePainter()?.let { painter ->
Icon( Icon(
@@ -36,12 +36,12 @@ import kotlinx.coroutines.launch
fun ConvosList( fun ConvosList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>, convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit, onConvosClick: (Long) -> Unit,
onConvosLongClick: (UiConvo) -> Unit, onConvosLongClick: (Long) -> Unit,
screenState: ConvosScreenState, screenState: ConvosScreenState,
state: LazyListState, state: LazyListState,
maxLines: Int, maxLines: Int,
onOptionClicked: (UiConvo, ConvoOption) -> Unit, onOptionClicked: (ConvoOption) -> Unit,
padding: PaddingValues padding: PaddingValues
) { ) {
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
@@ -1,79 +1,23 @@
package dev.meloda.fast.convos.presentation package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import dev.meloda.fast.convos.model.ConvoIntent
import androidx.compose.runtime.getValue import dev.meloda.fast.convos.model.ConvosScreenState
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
@Composable @Composable
fun ConvosRoute( fun ConvosRoute(
viewModel: ConvosViewModel, handleIntent: (ConvoIntent) -> Unit,
onBack: (() -> Unit)? = null, screenState: ConvosScreenState,
onError: (BaseError) -> Unit, isArchive: Boolean,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) { ) {
val screenState by viewModel.screenStateFlow.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigationFlow.collectAsStateWithLifecycle()
val convos by viewModel.uiConvosFlow.collectAsStateWithLifecycle()
val dialog by viewModel.dialogFlow.collectAsStateWithLifecycle()
val baseError by viewModel.baseErrorFlow.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginateFlow.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val shouldBeConsumed: Boolean = when (val navigation = navigationEvent) {
null -> false
is ConvoNavigation.CreateChat -> {
onNavigateToCreateChat?.invoke()
true
}
is ConvoNavigation.MessagesHistory -> {
onNavigateToMessagesHistory(navigation.peerId)
true
}
}
if (shouldBeConsumed) viewModel.onNavigationConsumed()
}
ConvosScreen( ConvosScreen(
onBack = { onBack?.invoke() }, handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
convos = convos.toImmutableList(), isArchive = isArchive,
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onArchiveActionClicked = { onNavigateToArchive?.invoke() },
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset,
onConsumeReselection = onScrolledToTop,
onErrorViewButtonClicked = {
if (baseError in listOf(BaseError.AccountBlocked, BaseError.SessionExpired)) {
onError(requireNotNull(baseError))
} else {
viewModel.onErrorButtonClicked()
}
}
) )
HandleDialogs( HandleDialogs(
handleIntent = handleIntent,
screenState = screenState, screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
) )
} }
@@ -56,25 +56,20 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvoIntent
import dev.meloda.fast.convos.model.ConvosScreenState import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.SegmentedButtonItem import dev.meloda.fast.ui.components.SegmentedButtonItem
import dev.meloda.fast.ui.components.SegmentedButtonsRow import dev.meloda.fast.ui.components.SegmentedButtonsRow
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.buildImmutableList import dev.meloda.fast.ui.util.buildImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
@@ -82,26 +77,14 @@ import kotlin.time.Duration.Companion.milliseconds
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalHazeMaterialsApi::class,
ExperimentalMaterial3ExpressiveApi::class,
) )
@Composable @Composable
fun ConvosScreen( fun ConvosScreen(
screenState: ConvosScreenState = ConvosScreenState.EMPTY, handleIntent: (ConvoIntent) -> Unit,
convos: ImmutableList<UiConvo> = emptyImmutableList(), screenState: ConvosScreenState,
baseError: BaseError? = null, isArchive: Boolean,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1 val maxLines = if (currentTheme.enableMultiline) 2 else 1
@@ -114,14 +97,14 @@ fun ConvosScreen(
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) { LaunchedEffect(currentTabReselected) {
if (currentTabReselected) { if (currentTabReselected) {
if (screenState.isArchive) { if (isArchive) {
onBack.invoke() handleIntent(ConvoIntent.Back)
} else { } else {
if (listState.firstVisibleItemIndex > 14) { if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14) listState.scrollToItem(14)
} }
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
onConsumeReselection() handleIntent(ConvoIntent.ConsumeScrollToTop)
} }
} }
} }
@@ -129,18 +112,18 @@ fun ConvosScreen(
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L.milliseconds) .debounce(500L.milliseconds)
.collectLatest(setScrollIndex) .collectLatest { handleIntent(ConvoIntent.SetScrollIndex(it)) }
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset } snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L.milliseconds) .debounce(500L.milliseconds)
.collectLatest(setScrollOffset) .collectLatest { handleIntent(ConvoIntent.SetScrollOffset(it)) }
} }
val paginationConditionMet by remember(canPaginate, listState) { val paginationConditionMet by remember(screenState.canPaginate, listState) {
derivedStateOf { derivedStateOf {
canPaginate && screenState.canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index (listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6) ?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
} }
@@ -148,7 +131,7 @@ fun ConvosScreen(
LaunchedEffect(paginationConditionMet) { LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) { if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet() handleIntent(ConvoIntent.PaginationConditionsMet)
} }
} }
@@ -183,7 +166,7 @@ fun ConvosScreen(
text = stringResource( text = stringResource(
id = when { id = when {
screenState.isLoading -> R.string.title_loading screenState.isLoading -> R.string.title_loading
screenState.isArchive -> R.string.title_archive isArchive -> R.string.title_archive
else -> R.string.title_convos else -> R.string.title_convos
} }
), ),
@@ -193,8 +176,8 @@ fun ConvosScreen(
) )
}, },
navigationIcon = { navigationIcon = {
if (screenState.isArchive) { if (isArchive) {
IconButton(onClick = onBack) { IconButton(onClick = { handleIntent(ConvoIntent.Back) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_arrow_back_round_24), painter = painterResource(R.drawable.ic_arrow_back_round_24),
contentDescription = null contentDescription = null
@@ -206,7 +189,7 @@ fun ConvosScreen(
val dropDownItems: List<@Composable () -> Unit> = buildList {} val dropDownItems: List<@Composable () -> Unit> = buildList {}
val items = buildImmutableList { val items = buildImmutableList {
if (!screenState.isArchive) { if (!isArchive) {
add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24)) add(SegmentedButtonItem("archive", R.drawable.ic_archive_round_24))
} }
@@ -225,8 +208,8 @@ fun ConvosScreen(
items = items, items = items,
onClick = { index -> onClick = { index ->
when (items[index].key) { when (items[index].key) {
"archive" -> onArchiveActionClicked() "archive" -> handleIntent(ConvoIntent.ArchiveClick)
"refresh" -> onRefresh() "refresh" -> handleIntent(ConvoIntent.Refresh)
"more" -> dropDownMenuExpanded = true "more" -> dropDownMenuExpanded = true
else -> Unit else -> Unit
@@ -263,7 +246,7 @@ fun ConvosScreen(
) )
val showHorizontalProgressBar by remember(screenState) { val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && convos.isNotEmpty() } derivedStateOf { screenState.isLoading && screenState.convos.isNotEmpty() }
} }
AnimatedVisibility(showHorizontalProgressBar) { AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
@@ -274,14 +257,14 @@ fun ConvosScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
if (!screenState.isArchive) { if (!isArchive) {
val offsetY by animateIntAsState( val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600 targetValue = if (listState.isScrollingUp()) 0 else 600
) )
Column { Column {
FloatingActionButton( FloatingActionButton(
onClick = onCreateChatButtonClicked, onClick = { handleIntent(ConvoIntent.CreateChatClick) },
modifier = Modifier.offset { modifier = Modifier.offset {
IntOffset(0, offsetY) IntOffset(0, offsetY)
} }
@@ -298,14 +281,15 @@ fun ConvosScreen(
} }
) { padding -> ) { padding ->
when { when {
baseError != null -> { // TODO: 30.05.2026, Danil Nikolaev: move to UI State
VkErrorView( // baseError != null -> {
baseError = baseError, // VkErrorView(
onButtonClick = onErrorViewButtonClicked // baseError = baseError,
) // onButtonClick = onErrorViewButtonClicked
} // )
// }
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader() screenState.isLoading && screenState.convos.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -318,7 +302,7 @@ fun ConvosScreen(
.padding(bottom = padding.calculateBottomPadding()), .padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState, state = pullToRefreshState,
isRefreshing = screenState.isLoading, isRefreshing = screenState.isLoading,
onRefresh = onRefresh, onRefresh = { handleIntent(ConvoIntent.Refresh) },
indicator = { indicator = {
PullToRefreshDefaults.Indicator( PullToRefreshDefaults.Indicator(
state = pullToRefreshState, state = pullToRefreshState,
@@ -330,9 +314,9 @@ fun ConvosScreen(
} }
) { ) {
ConvosList( ConvosList(
convos = convos, convos = screenState.convos,
onConvosClick = onConvoItemClicked, onConvosClick = { handleIntent(ConvoIntent.ItemClick(it)) },
onConvosLongClick = onConvoItemLongClicked, onConvosLongClick = { handleIntent(ConvoIntent.ItemLongClick(it)) },
screenState = screenState, screenState = screenState,
state = listState, state = listState,
maxLines = maxLines, maxLines = maxLines,
@@ -341,14 +325,14 @@ fun ConvosScreen(
} else { } else {
Modifier Modifier
}.fillMaxSize(), }.fillMaxSize(),
onOptionClicked = onOptionClicked, onOptionClicked = { handleIntent(ConvoIntent.OptionItemClick(it)) },
padding = padding padding = padding
) )
if (convos.isEmpty()) { if (screenState.convos.isEmpty()) {
NoItemsView( NoItemsView(
buttonText = stringResource(R.string.action_refresh), buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh onButtonClick = { handleIntent(ConvoIntent.Refresh) }
) )
} }
} }