* refactor Conversation -> Convo

* extract Message and Convo mappers to core/domain module
* improve reply container text
This commit is contained in:
2025-12-17 17:16:02 +03:00
parent 7b6571f208
commit 45ee0acea5
125 changed files with 2361 additions and 2005 deletions
+1
View File
@@ -0,0 +1 @@
/build
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.fast.android.feature)
alias(libs.plugins.fast.android.library.compose)
}
android {
namespace = "dev.meloda.fast.convos"
}
dependencies {
implementation(projects.core.common)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.ui)
implementation(libs.bundles.nanokt)
implementation(libs.koin.android)
implementation(libs.koin.androidx.compose)
implementation(platform(libs.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.coil.compose)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.eithernet)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlin.serialization)
}
@@ -0,0 +1,746 @@
package dev.meloda.fast.convos
import android.content.Context
import android.content.res.Resources
import android.os.Bundle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull
import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.extensions.updateValue
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvoNavigation
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.model.InteractionJob
import dev.meloda.fast.convos.model.NewInteractionException
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.LoadConvosByIdUseCase
import dev.meloda.fast.domain.LongPollUpdatesParser
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.VkConvo
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class ConvosViewModel(
updatesParser: LongPollUpdatesParser,
private val filter: ConvosFilter,
private val convoUseCase: ConvoUseCase,
private val messagesUseCase: MessagesUseCase,
private val resources: Resources,
private val userSettings: UserSettings,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val loadConvosByIdUseCase: LoadConvosByIdUseCase
) : ViewModel() {
private val _screenState = MutableStateFlow(ConvosScreenState.EMPTY)
val screenState = _screenState.asStateFlow()
private val _navigation = MutableStateFlow<ConvoNavigation?>(null)
val navigation = _navigation.asStateFlow()
private val _dialog = MutableStateFlow<ConvoDialog?>(null)
val dialog = _dialog.asStateFlow()
private val _convos = MutableStateFlow<List<VkConvo>>(emptyList())
val convos = _convos.asStateFlow()
private val _uiConvos = MutableStateFlow<List<UiConvo>>(emptyList())
val uiConvos = _uiConvos.asStateFlow()
private val pinnedConvosCount = convos.map { convos ->
convos.count(VkConvo::isPinned)
}.stateIn(viewModelScope, SharingStarted.Eagerly, 0)
private val _baseError = MutableStateFlow<BaseError?>(null)
val baseError = _baseError.asStateFlow()
private val _currentOffset = MutableStateFlow(0)
val currentOffset = _currentOffset.asStateFlow()
private val _canPaginate = MutableStateFlow(false)
val canPaginate = _canPaginate.asStateFlow()
private val expandedConvoId = MutableStateFlow(0L)
private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val interactionsTimers = hashMapOf<Long, InteractionJob?>()
init {
_screenState.updateValue { copy(isArchive = filter == ConvosFilter.ARCHIVE) }
loadConvos()
updatesParser.onNewMessage(::handleNewMessage)
updatesParser.onMessageEdited(::handleEditedMessage)
updatesParser.onMessageIncomingRead(::handleReadIncomingMessage)
updatesParser.onMessageOutgoingRead(::handleReadOutgoingMessage)
updatesParser.onInteractions(::handleInteraction)
updatesParser.onChatMajorChanged(::handleChatMajorChanged)
updatesParser.onChatMinorChanged(::handleChatMinorChanged)
updatesParser.onChatCleared(::handleChatClearing)
updatesParser.onChatArchived(::handleChatArchived)
userSettings.useContactNames.listenValue(viewModelScope) {
syncUiConvos()
}
}
fun onNavigationConsumed() {
_navigation.setValue { null }
}
fun onDialogConfirmed(dialog: ConvoDialog, bundle: Bundle) {
onDialogDismissed(dialog)
when (dialog) {
is ConvoDialog.ConvoDelete -> {
deleteConvo(dialog.convoId)
}
is ConvoDialog.ConvoPin -> {
pinConvo(dialog.convoId, true)
}
is ConvoDialog.ConvoUnpin -> {
pinConvo(dialog.convoId, false)
}
is ConvoDialog.ConvoArchive -> {
archiveConvo(dialog.convoId, true)
}
is ConvoDialog.ConvoUnarchive -> {
archiveConvo(dialog.convoId, false)
}
}
expandedConvoId.setValue { 0 }
syncUiConvos()
}
fun onDialogDismissed(dialog: ConvoDialog) {
_dialog.setValue { null }
}
fun onDialogItemPicked(dialog: ConvoDialog, bundle: Bundle) {
when (dialog) {
is ConvoDialog.ConvoDelete -> Unit
is ConvoDialog.ConvoPin -> Unit
is ConvoDialog.ConvoUnpin -> Unit
is ConvoDialog.ConvoArchive -> Unit
is ConvoDialog.ConvoUnarchive -> Unit
}
}
fun onErrorButtonClicked() {
when (baseError.value) {
null -> Unit
is BaseError.ConnectionError,
is BaseError.InternalError,
is BaseError.SimpleError,
is BaseError.UnknownError -> onRefresh()
else -> Unit
}
}
fun onPaginationConditionsMet() {
_currentOffset.update { convos.value.size }
loadConvos()
}
fun onRefresh() {
onErrorConsumed()
loadConvos(offset = 0)
}
fun onConvoItemClick(convo: UiConvo) {
collapseConvos()
_navigation.setValue { ConvoNavigation.MessagesHistory(peerId = convo.id) }
}
fun onConvoItemLongClick(convo: UiConvo) {
expandedConvoId.setValue {
if (convo.isExpanded) 0
else convo.id
}
syncUiConvos()
}
fun onOptionClicked(
convo: UiConvo,
option: ConvoOption
) {
when (option) {
ConvoOption.Delete -> {
_dialog.setValue { ConvoDialog.ConvoDelete(convo.id) }
}
ConvoOption.MarkAsRead -> {
convo.lastMessageId?.let { lastMessageId ->
readConvo(
peerId = convo.id,
startMessageId = lastMessageId
)
collapseConvos()
}
}
ConvoOption.Pin -> {
_dialog.setValue { ConvoDialog.ConvoPin(convo.id) }
}
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() {
_baseError.setValue { null }
}
fun setScrollIndex(index: Int) {
_screenState.setValue { old -> old.copy(scrollIndex = index) }
}
fun setScrollOffset(offset: Int) {
_screenState.setValue { old -> old.copy(scrollOffset = offset) }
}
fun onCreateChatButtonClicked() {
_navigation.setValue { ConvoNavigation.CreateChat }
}
private fun collapseConvos() {
expandedConvoId.setValue { 0 }
syncUiConvos()
}
private fun loadConvos(
offset: Int = currentOffset.value
) {
convoUseCase.getConvos(
count = LOAD_COUNT,
offset = offset,
filter = filter
).listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
val newBaseError = VkUtils.parseError(error)
_baseError.update { newBaseError }
},
success = { response ->
val convos = response
val fullConvos = if (offset == 0) {
convos
} else {
this.convos.value.plus(convos)
}
val itemsCountSufficient = response.size == LOAD_COUNT
val paginationExhausted = !itemsCountSufficient &&
this.convos.value.isNotEmpty()
_screenState.updateValue {
copy(isPaginationExhausted = paginationExhausted)
}
val imagesToPreload =
response.mapNotNull { it.extractAvatar().extractUrl() }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
convoUseCase.storeConvos(response)
_convos.emit(fullConvos)
syncUiConvos()
_canPaginate.setValue { itemsCountSufficient }
}
)
_screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun deleteConvo(peerId: Long) {
convoUseCase.delete(peerId).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@processState
newConvos.removeAt(convoIndex)
_convos.update { newConvos.sorted() }
syncUiConvos()
}
)
_screenState.emit(screenState.value.copy(isLoading = state.isLoading()))
}
}
private fun pinConvo(peerId: Long, pin: Boolean) {
convoUseCase.changePinState(peerId, pin)
.listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
handleChatMajorChanged(
LongPollParsedEvent.ChatMajorChanged(
peerId = peerId,
majorId = if (pin) {
pinnedConvosCount.value.plus(1) * 16
} else {
0
}
)
)
}
)
_screenState.setValue { old -> old.copy(isLoading = state.isLoading()) }
}
}
private fun archiveConvo(peerId: Long, archive: Boolean) {
convoUseCase.changeArchivedState(peerId, archive)
.listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
convos.value.find { it.id == peerId }?.let { convo ->
handleChatArchived(
LongPollParsedEvent.ChatArchived(
convo = convo,
archived = archive
)
)
}
}
)
}
}
// TODO: 03-Apr-25, Danil Nikolaev: handle business messages
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) {
if (event.inArchive != (filter == ConvosFilter.ARCHIVE)) return
loadConvosByIdUseCase(
peerIds = listOf(message.peerId),
extended = true,
fields = VkConstants.ALL_FIELDS
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = { response ->
val convo = (response.firstOrNull() ?: return@listenValue)
.copy(lastMessage = message)
newConvos.add(pinnedConvosCount.value, convo)
_convos.update { newConvos.sorted() }
syncUiConvos()
}
)
}
} else {
val convo = newConvos[convoIndex]
var newConvo = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId,
unreadCount = if (message.isOut) convo.unreadCount
else convo.unreadCount + 1
)
interactionsTimers[convo.id]?.let { job ->
if (job.interactionType == InteractionType.Typing
&& message.fromId in convo.interactionIds
) {
val newInteractionIds = newConvo.interactionIds.filter { id ->
id != message.fromId
}
newConvo = newConvo.copy(
interactionType = if (newInteractionIds.isEmpty()) -1 else {
newConvo.interactionType
},
interactionIds = newInteractionIds
)
}
}
if (convo.isPinned()) {
newConvos[convoIndex] = newConvo
} else {
newConvos.removeAt(convoIndex)
val toPosition = pinnedConvosCount.value
newConvos.add(toPosition, newConvo)
}
_convos.update { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleEditedMessage(event: LongPollParsedEvent.MessageEdited) {
val message = event.message
val newConvos = convos.value.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == message.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
val convo = newConvos[convoIndex]
newConvos[convoIndex] = convo.copy(
lastMessage = message,
lastMessageId = message.id,
lastCmId = message.cmId
)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleReadIncomingMessage(event: LongPollParsedEvent.IncomingMessageRead) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
inReadCmId = event.cmId,
unreadCount = event.unreadCount
)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleReadOutgoingMessage(event: LongPollParsedEvent.OutgoingMessageRead) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(
outReadCmId = event.cmId,
unreadCount = event.unreadCount
)
_convos.update { newConvos }
syncUiConvos()
}
}
private fun handleInteraction(event: LongPollParsedEvent.Interaction) {
val interactionType = event.interactionType
val peerId = event.peerId
val userIds = event.userIds
val newConvos = convos.value.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId }
if (convoAndIndex != null) {
newConvos[convoAndIndex.first] =
convoAndIndex.second.copy(
interactionType = interactionType.value,
interactionIds = userIds
)
_convos.update { newConvos }
syncUiConvos()
interactionsTimers[peerId]?.let { interactionJob ->
if (interactionJob.interactionType == interactionType) {
interactionJob.timerJob.cancel(NewInteractionException())
}
}
var timeoutAction: (() -> Unit)? = null
val timerJob = createTimerFlow(
time = 6,
onTimeoutAction = { timeoutAction?.invoke() }
).launchIn(viewModelScope)
val newInteractionJob = InteractionJob(
interactionType = interactionType,
timerJob = timerJob
)
interactionsTimers[peerId] = newInteractionJob
timeoutAction = {
stopInteraction(peerId, newInteractionJob)
}
}
}
private fun stopInteraction(peerId: Long, interactionJob: InteractionJob) {
interactionsTimers[peerId] ?: return
val newConvos = convos.value.toMutableList()
val convoAndIndex =
newConvos.findWithIndex { it.id == peerId } ?: return
newConvos[convoAndIndex.first] =
convoAndIndex.second.copy(
interactionType = -1,
interactionIds = emptyList()
)
_convos.update { newConvos }
syncUiConvos()
interactionJob.timerJob.cancel()
interactionsTimers[peerId] = null
}
private fun handleChatMajorChanged(event: LongPollParsedEvent.ChatMajorChanged) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(majorId = event.majorId)
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatMinorChanged(event: LongPollParsedEvent.ChatMinorChanged) {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos[convoIndex] =
newConvos[convoIndex].copy(minorId = event.minorId)
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatClearing(event: LongPollParsedEvent.ChatCleared) {
val newConvos = convos.value.toMutableList()
val convoIndex = newConvos.indexOfFirstOrNull { it.id == event.peerId }
if (convoIndex == null) { // диалога нет в списке
// pizdets
} else {
newConvos.removeAt(convoIndex)
_convos.setValue { newConvos.sorted() }
syncUiConvos()
}
}
private fun handleChatArchived(event: LongPollParsedEvent.ChatArchived) {
val convo = event.convo
val newConvos = convos.value.toMutableList()
when (filter) {
ConvosFilter.BUSINESS_NOTIFY -> Unit
ConvosFilter.ARCHIVE -> {
if (event.archived) {
newConvos.add(0, convo)
} else {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
}
_convos.update { newConvos }
syncUiConvos()
}
else -> {
if (event.archived) {
val index = newConvos.indexOfFirstOrNull { it.id == convo.id }
if (index == null) return
newConvos.removeAt(index)
} else {
newConvos.add(pinnedConvosCount.value, convo)
}
_convos.update { newConvos.sorted() }
syncUiConvos()
}
}
}
private fun readConvo(peerId: Long, startMessageId: Long) {
messagesUseCase.markAsRead(
peerId = peerId,
startMessageId = startMessageId
).listenValue(viewModelScope) { state ->
state.processState(
error = {},
success = {
val newConvos = convos.value.toMutableList()
val convoIndex =
newConvos.indexOfFirstOrNull { it.id == peerId }
?: return@listenValue
newConvos[convoIndex] =
newConvos[convoIndex].copy(inRead = startMessageId)
_convos.update { newConvos }
syncUiConvos()
}
)
}
}
private fun List<VkConvo>.sorted(): List<VkConvo> {
val newConvos = toMutableList()
val pinnedConvos = newConvos
.filter(VkConvo::isPinned)
.sortedWith { c1, c2 ->
val diff = c2.majorId - c1.majorId
if (diff == 0) {
c2.minorId - c1.minorId
} else {
diff
}
}
newConvos.removeAll(pinnedConvos)
newConvos.sortWith { c1, c2 ->
(c2.lastMessage?.date ?: 0) - (c1.lastMessage?.date ?: 0)
}
newConvos.addAll(0, pinnedConvos)
return newConvos
}
private fun syncUiConvos(): List<UiConvo> {
val convos = convos.value
val newUiConvos = convos.map { convo ->
val options = mutableListOf<ConvoOption>()
convo.lastMessage?.run {
if (!convo.isRead() && !this.isOut) {
options += ConvoOption.MarkAsRead
}
}
val convosSize = this.convos.value.size
val pinnedCount = pinnedConvosCount.value
val canPinOneMoreDialog =
convosSize > 4 && pinnedCount < 5 && !convo.isPinned()
if (convo.isPinned()) {
options += ConvoOption.Unpin
} else if (canPinOneMoreDialog) {
options += ConvoOption.Pin
}
when (filter) {
ConvosFilter.ARCHIVE -> ConvoOption.Unarchive
ConvosFilter.UNREAD,
ConvosFilter.ALL -> ConvoOption.Archive
ConvosFilter.BUSINESS_NOTIFY -> null
}?.let(options::add)
options += ConvoOption.Delete
convo.asPresentation(
resources = resources,
useContactName = useContactNames,
isExpanded = expandedConvoId.value == convo.id,
options = options.toImmutableList()
)
}
_uiConvos.setValue { newUiConvos }
return newUiConvos
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -0,0 +1,37 @@
package dev.meloda.fast.convos.di
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.domain.ConvoUseCase
import dev.meloda.fast.domain.ConvoUseCaseImpl
import dev.meloda.fast.model.ConvosFilter
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.core.scope.Scope
import org.koin.dsl.bind
import org.koin.dsl.module
val convosModule = module {
viewModel(named(ConvosFilter.ALL)) {
createConvosViewModel(ConvosFilter.ALL)
}
viewModel(named(ConvosFilter.ARCHIVE)) {
createConvosViewModel(ConvosFilter.ARCHIVE)
}
singleOf(::ConvoUseCaseImpl) bind ConvoUseCase::class
}
private fun Scope.createConvosViewModel(filter: ConvosFilter): ConvosViewModel {
return ConvosViewModel(
filter = filter,
updatesParser = get(),
convoUseCase = get(),
messagesUseCase = get(),
resources = get(),
userSettings = get(),
imageLoader = get(),
applicationContext = get(),
loadConvosByIdUseCase = get()
)
}
@@ -0,0 +1,12 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
sealed class ConvoDialog {
data class ConvoPin(val convoId: Long) : ConvoDialog()
data class ConvoUnpin(val convoId: Long) : ConvoDialog()
data class ConvoDelete(val convoId: Long) : ConvoDialog()
data class ConvoArchive(val convoId: Long) : ConvoDialog()
data class ConvoUnarchive(val convoId: Long) : ConvoDialog()
}
@@ -0,0 +1,11 @@
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,27 @@
package dev.meloda.fast.convos.model
import androidx.compose.runtime.Immutable
@Immutable
data class ConvosScreenState(
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val profileImageUrl: String?,
val scrollIndex: Int,
val scrollOffset: Int,
val isArchive: Boolean
) {
companion object {
val EMPTY: ConvosScreenState = ConvosScreenState(
isLoading = true,
isPaginating = false,
isPaginationExhausted = false,
profileImageUrl = null,
scrollIndex = 0,
scrollOffset = 0,
isArchive = false
)
}
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.convos.model
import dev.meloda.fast.model.InteractionType
import kotlinx.coroutines.Job
data class InteractionJob(
val interactionType: InteractionType,
val timerJob: Job
)
@@ -0,0 +1,5 @@
package dev.meloda.fast.convos.model
import kotlinx.coroutines.CancellationException
class NewInteractionException : CancellationException()
@@ -0,0 +1,67 @@
package dev.meloda.fast.convos.navigation
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.navigation
import dev.meloda.fast.convos.ConvosViewModel
import dev.meloda.fast.convos.presentation.ConvosRoute
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.ConvosFilter
import dev.meloda.fast.ui.extensions.getOrThrow
import dev.meloda.fast.ui.theme.LocalNavController
import kotlinx.serialization.Serializable
import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koin.core.qualifier.named
@Serializable
object ConvoGraph
@Serializable
object Convos
@Serializable
object Archive
fun NavGraphBuilder.convosGraph(
activity: AppCompatActivity,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (id: Long) -> Unit,
onNavigateToCreateChat: () -> Unit,
onScrolledToTop: () -> Unit
) {
navigation<ConvoGraph>(
startDestination = Convos
) {
val convosViewModel: ConvosViewModel = with(activity) {
getViewModel(qualifier = named(ConvosFilter.ALL))
}
composable<Convos> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
viewModel = convosViewModel,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onNavigateToCreateChat = onNavigateToCreateChat,
onNavigateToArchive = { navController.navigate(Archive) },
onScrolledToTop = onScrolledToTop
)
}
composable<Archive> {
val navController = LocalNavController.getOrThrow()
ConvosRoute(
viewModel = with(activity) {
getViewModel<ConvosViewModel>(
qualifier = named(ConvosFilter.ARCHIVE)
)
},
onBack = navController::navigateUp,
onError = onError,
onNavigateToMessagesHistory = onNavigateToMessagesHistory,
onScrolledToTop = onScrolledToTop
)
}
}
}
@@ -0,0 +1,74 @@
package dev.meloda.fast.convos.presentation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf
import dev.meloda.fast.convos.model.ConvoDialog
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.R
@Composable
fun HandleDialogs(
screenState: ConvosScreenState,
dialog: ConvoDialog?,
onConfirmed: (ConvoDialog, Bundle) -> Unit = { _, _ -> },
onDismissed: (ConvoDialog) -> Unit = {},
onItemPicked: (ConvoDialog, Bundle) -> Unit = { _, _ -> }
) {
when (dialog) {
null -> Unit
is ConvoDialog.ConvoArchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_archive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_archive),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConvoDialog.ConvoUnarchive -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_unarchive_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unarchive),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConvoDialog.ConvoDelete -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_delete_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_delete),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConvoDialog.ConvoPin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_pin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_pin),
cancelText = stringResource(id = R.string.cancel)
)
}
is ConvoDialog.ConvoUnpin -> {
MaterialDialog(
onDismissRequest = { onDismissed(dialog) },
title = stringResource(id = R.string.confirm_unpin_convo),
confirmAction = { onConfirmed(dialog, bundleOf()) },
confirmText = stringResource(id = R.string.action_unpin),
cancelText = stringResource(id = R.string.cancel)
)
}
}
}
@@ -0,0 +1,400 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ElevatedAssistChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.model.vk.ConvoOption
import dev.meloda.fast.ui.model.vk.UiConvo
import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString
import dev.meloda.fast.ui.R
val BirthdayColor = Color(0xffb00b69)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ConvoItem(
onItemClick: (UiConvo) -> Unit,
onItemLongClick: (convo: UiConvo) -> Unit,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
maxLines: Int,
isUserAccount: Boolean,
convo: UiConvo,
modifier: Modifier = Modifier
) {
val hapticFeedback = LocalHapticFeedback.current
val bottomStartCornerRadius by animateDpAsState(
targetValue = if (convo.isExpanded) 10.dp else 34.dp,
label = "bottomStartCornerRadius"
)
Box(
modifier = modifier
.fillMaxWidth()
.combinedClickable(
onClick = { onItemClick(convo) },
onLongClick = {
onItemLongClick(convo)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
)
) {
val showBackground by remember(convo) {
derivedStateOf { convo.isUnread || convo.isExpanded }
}
AnimatedVisibility(
visible = showBackground,
modifier = Modifier
.matchParentSize()
.padding(start = 8.dp),
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.matchParentSize()
.clip(
RoundedCornerShape(
topStart = 34.dp,
bottomStart = bottomStartCornerRadius
)
)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp))
)
}
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(16.dp))
Box(modifier = Modifier.size(56.dp)) {
if (isUserAccount) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(32.dp),
painter = painterResource(id = R.drawable.ic_round_bookmark_border_24),
contentDescription = "Favorites icon",
tint = MaterialTheme.colorScheme.onPrimary
)
}
} else {
val avatarImage = convo.avatar?.getImage()
if (avatarImage is Painter) {
Icon(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
painter = avatarImage,
contentDescription = "Avatar",
tint = MaterialTheme.colorScheme.onBackground
)
} else {
AsyncImage(
model = avatarImage,
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
)
}
}
if (convo.isPinned) {
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(minWidth = 20.dp, minHeight = 20.dp)
.background(MaterialTheme.colorScheme.outline)
) {
Icon(
modifier = Modifier
.height(14.dp)
.align(Alignment.Center),
painter = painterResource(id = R.drawable.ic_round_push_pin_24),
contentDescription = "Pin icon",
tint = Color.White
)
}
}
if (convo.isOnline) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(
if (convo.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
}
)
.padding(2.dp)
.align(Alignment.BottomEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
if (convo.isBirthday) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(16.dp)
.background(
if (convo.isUnread) {
MaterialTheme.colorScheme.surfaceColorAtElevation(5.dp)
} else {
MaterialTheme.colorScheme.background
}
)
.padding(2.dp)
.align(Alignment.TopEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(BirthdayColor)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(10.dp),
painter = painterResource(id = R.drawable.round_cake_24),
contentDescription = "Birthday icon",
tint = Color.White
)
}
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = convo.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
)
Row {
if (convo.interactionText != null) {
Text(
text = convo.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
DotsFlashing(
modifier = Modifier
.align(Alignment.Bottom)
.padding(bottom = 7.dp),
dotSize = 4.dp,
dotColor = MaterialTheme.colorScheme.primary
)
} else {
convo.attachmentImage?.getResourcePainter()?.let { painter ->
Column {
Spacer(modifier = Modifier.height(4.dp))
Icon(
modifier = Modifier.size(14.dp),
painter = painter,
contentDescription = "attachment image",
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.width(2.dp))
}
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
modifier = Modifier.weight(1f),
text = kotlin.run {
val builder =
AnnotatedString.Builder(convo.message.text)
convo.message.spanStyles.map { spanStyleRange ->
val updatedSpanStyle =
if (spanStyleRange.item.color == Color.Red) {
spanStyleRange.item.copy(color = MaterialTheme.colorScheme.primary)
} else {
spanStyleRange.item
}
builder.addStyle(
style = updatedSpanStyle,
start = spanStyleRange.start,
end = spanStyleRange.end
)
}
convo.message.paragraphStyles.forEach { style ->
builder.addStyle(
style = style.item,
start = style.start,
end = style.end
)
}
builder.toAnnotatedString()
},
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
Spacer(modifier = Modifier.width(4.dp))
Column {
LocalContentAlpha(alpha = ContentAlpha.medium) {
Text(
text = convo.date,
style = MaterialTheme.typography.bodySmall
)
}
convo.unreadCount?.let { count ->
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier = Modifier
.clip(CircleShape)
.defaultMinSize(
minWidth = 20.dp,
minHeight = 20.dp
)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.CenterHorizontally)
.padding(horizontal = if (count.length > 1) 2.dp else 0.dp)
) {
Text(
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center),
text = count,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
Spacer(modifier = Modifier.width(24.dp))
}
AnimatedVisibility(convo.isExpanded) {
Column(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.padding(start = 8.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(4.dp))
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp)
) {
items(convo.options.toList()) { option ->
ElevatedAssistChip(
onClick = { onOptionClicked(convo, option) },
leadingIcon = {
option.icon.getResourcePainter()?.let { painter ->
Icon(
painter = painter,
contentDescription = "Chip icon",
modifier = Modifier.size(16.dp)
)
}
},
label = {
Text(text = option.title.getString().orEmpty())
}
)
Spacer(Modifier.width(8.dp))
}
}
}
}
val bottomSpacerHeight by animateDpAsState(
targetValue = if (convo.isExpanded) 4.dp else 8.dp,
label = "bottomSpacerHeight"
)
Spacer(modifier = Modifier.height(bottomSpacerHeight))
}
}
}
@@ -0,0 +1,126 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.R
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.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ConvosList(
modifier: Modifier = Modifier,
convos: ImmutableList<UiConvo>,
onConvosClick: (UiConvo) -> Unit,
onConvosLongClick: (UiConvo) -> Unit,
screenState: ConvosScreenState,
state: LazyListState,
maxLines: Int,
onOptionClicked: (UiConvo, ConvoOption) -> Unit,
padding: PaddingValues
) {
val theme = LocalThemeConfig.current
val coroutineScope = rememberCoroutineScope()
LazyColumn(
modifier = modifier,
state = state
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
Spacer(modifier = Modifier.height(8.dp))
}
items(
items = convos.values,
key = UiConvo::id,
) { convo ->
val isUserAccount by remember(convo) {
derivedStateOf {
convo.id == UserConfig.userId
}
}
ConvoItem(
onItemClick = onConvosClick,
onItemLongClick = onConvosLongClick,
onOptionClicked = onOptionClicked,
maxLines = maxLines,
isUserAccount = isUserAccount,
convo = convo,
modifier =
if (theme.enableAnimations) Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
)
Spacer(modifier = Modifier.height(8.dp))
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.then(
if (theme.enableAnimations)
Modifier.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
else Modifier
),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (screenState.isPaginating) {
CircularProgressIndicator()
}
if (screenState.isPaginationExhausted) {
IconButton(
onClick = {
coroutineScope.launch(Dispatchers.Main) {
state.scrollToItem(14)
state.animateScrollToItem(0)
}
},
colors = IconButtonDefaults.filledIconButtonColors()
) {
Icon(
painter = painterResource(R.drawable.round_keyboard_arrow_up_24px),
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
}
@@ -0,0 +1,80 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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
fun ConvosRoute(
viewModel: ConvosViewModel,
onBack: (() -> Unit)? = null,
onError: (BaseError) -> Unit,
onNavigateToMessagesHistory: (convoId: Long) -> Unit,
onNavigateToCreateChat: (() -> Unit)? = null,
onNavigateToArchive: (() -> Unit)? = null,
onScrolledToTop: () -> Unit,
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val navigationEvent by viewModel.navigation.collectAsStateWithLifecycle()
val convos by viewModel.uiConvos.collectAsStateWithLifecycle()
val dialog by viewModel.dialog.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.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(
onBack = { onBack?.invoke() },
screenState = screenState,
convos = convos.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
onConvoItemClicked = viewModel::onConvoItemClick,
onConvoItemLongClicked = viewModel::onConvoItemLongClick,
onOptionClicked = viewModel::onOptionClicked,
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onRefreshDropdownItemClicked = viewModel::onRefresh,
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(
screenState = screenState,
dialog = dialog,
onConfirmed = viewModel::onDialogConfirmed,
onDismissed = viewModel::onDialogDismissed,
onItemPicked = viewModel::onDialogItemPicked
)
}
@@ -0,0 +1,363 @@
package dev.meloda.fast.convos.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.convos.model.ConvosScreenState
import dev.meloda.fast.convos.navigation.ConvoGraph
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
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.LocalHazeState
import dev.meloda.fast.ui.theme.LocalReselectedTab
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import dev.meloda.fast.ui.util.emptyImmutableList
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class, ExperimentalMaterial3ExpressiveApi::class,
)
@Composable
fun ConvosScreen(
screenState: ConvosScreenState = ConvosScreenState.EMPTY,
convos: ImmutableList<UiConvo> = emptyImmutableList(),
baseError: BaseError? = null,
canPaginate: Boolean = false,
onBack: () -> Unit = {},
onConvoItemClicked: (convo: UiConvo) -> Unit = {},
onConvoItemLongClicked: (convo: UiConvo) -> Unit = {},
onOptionClicked: (UiConvo, ConvoOption) -> Unit = { _, _ -> },
onPaginationConditionsMet: () -> Unit = {},
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onArchiveActionClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {},
onConsumeReselection: () -> Unit = {},
onErrorViewButtonClicked: () -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines = if (currentTheme.enableMultiline) 2 else 1
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = screenState.scrollIndex,
initialFirstVisibleItemScrollOffset = screenState.scrollOffset
)
val currentTabReselected = LocalReselectedTab.current[ConvoGraph] == true
LaunchedEffect(currentTabReselected) {
if (currentTabReselected) {
if (screenState.isArchive) {
onBack.invoke()
} else {
if (listState.firstVisibleItemIndex > 14) {
listState.scrollToItem(14)
}
listState.animateScrollToItem(0)
onConsumeReselection()
}
}
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(500L)
.collectLatest(setScrollIndex)
}
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemScrollOffset }
.debounce(500L)
.collectLatest(setScrollOffset)
}
val paginationConditionMet by remember(canPaginate, listState) {
derivedStateOf {
canPaginate &&
(listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (listState.layoutInfo.totalItemsCount - 6)
}
}
LaunchedEffect(paginationConditionMet) {
if (paginationConditionMet && !screenState.isPaginating) {
onPaginationConditionsMet()
}
}
val hazeState = LocalHazeState.current
var dropDownMenuExpanded by remember {
mutableStateOf(false)
}
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (!listState.canScrollBackward) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Column(modifier = Modifier.fillMaxWidth()) {
TopAppBar(
title = {
Text(
text = stringResource(
id = when {
screenState.isLoading -> R.string.title_loading
screenState.isArchive -> R.string.title_archive
else -> R.string.title_convos
}
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
navigationIcon = {
if (screenState.isArchive) {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.round_arrow_back_24px),
contentDescription = null
)
}
}
},
actions = {
if (!screenState.isArchive) {
IconButton(onClick = onArchiveActionClicked) {
Icon(
painter = painterResource(R.drawable.outline_archive_24),
contentDescription = null
)
}
}
val dropDownItems = mutableListOf<@Composable () -> Unit>()
if (AppSettings.General.showManualRefreshOptions) {
dropDownItems += {
DropdownMenuItem(
onClick = {
onRefreshDropdownItemClicked()
dropDownMenuExpanded = false
},
text = {
Text(text = stringResource(id = R.string.action_refresh))
},
leadingIcon = {
Icon(
painter = painterResource(R.drawable.round_refresh_24px),
contentDescription = null
)
}
)
}
}
if (dropDownItems.isNotEmpty()) {
IconButton(onClick = { dropDownMenuExpanded = true }) {
Icon(
painter = painterResource(R.drawable.round_more_vert_24px),
contentDescription = null
)
}
}
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropDownMenuExpanded,
onDismissRequest = { dropDownMenuExpanded = false },
offset = DpOffset(x = (-4).dp, y = (-60).dp)
) {
dropDownItems.forEach { it.invoke() }
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
),
modifier = Modifier
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.regular(toolbarContainerColor)
)
} else Modifier
)
.fillMaxWidth(),
)
val showHorizontalProgressBar by remember(screenState) {
derivedStateOf { screenState.isLoading && convos.isNotEmpty() }
}
AnimatedVisibility(showHorizontalProgressBar) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
AnimatedVisibility(!showHorizontalProgressBar) {
HorizontalDivider()
}
}
},
floatingActionButton = {
if (!screenState.isArchive) {
val offsetY by animateIntAsState(
targetValue = if (listState.isScrollingUp()) 0 else 600
)
Column {
FloatingActionButton(
onClick = onCreateChatButtonClicked,
modifier = Modifier.offset {
IntOffset(0, offsetY)
}
) {
Icon(
painter = painterResource(id = R.drawable.round_create_24),
contentDescription = "Add chat button"
)
}
Spacer(modifier = Modifier.height(LocalBottomPadding.current))
}
}
}
) { padding ->
when {
baseError != null -> {
VkErrorView(
baseError = baseError,
onButtonClick = onErrorViewButtonClicked
)
}
screenState.isLoading && convos.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox(
modifier = Modifier
.fillMaxSize()
.padding(start = padding.calculateStartPadding(LayoutDirection.Ltr))
.padding(end = padding.calculateEndPadding(LayoutDirection.Ltr))
.padding(bottom = padding.calculateBottomPadding()),
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
onRefresh = onRefresh,
indicator = {
PullToRefreshDefaults.Indicator(
state = pullToRefreshState,
isRefreshing = screenState.isLoading,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = padding.calculateTopPadding()),
)
}
) {
ConvosList(
convos = convos,
onConvosClick = onConvoItemClicked,
onConvosLongClick = onConvoItemLongClicked,
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
onOptionClicked = onOptionClicked,
padding = padding
)
if (convos.isEmpty()) {
NoItemsView(
buttonText = stringResource(R.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
}